Compare commits

..

24 Commits

Author SHA1 Message Date
Otto
9b20f4cd13 refactor: simplify ExecutionQueue docstrings and move test file
- Trim verbose BUG FIX docstring to concise 3-line note
- Remove redundant method docstrings (add, get, empty)
- Move test file to backend/data/ with proper pytest conventions
- Add note about ProcessPoolExecutor migration for future devs

Co-authored-by: Zamil Majdy <majdyz@users.noreply.github.com>
2026-02-08 16:11:35 +00:00
Nikhil Bhagat
a3d0f9cbd2 fix(backend): format test_execution_queue.py and remove unused variable 2025-12-14 19:37:29 +05:45
Nikhil Bhagat
02ddb51446 Added test_execution_queue.py and test the execution part and the test got passed 2025-12-14 19:05:14 +05:45
Nikhil Bhagat
750e096f15 fix(backend): replace multiprocessing.Manager().Queue() with queue.Queue()
ExecutionQueue was unnecessarily using multiprocessing.Manager().Queue() which
spawns a subprocess for IPC. Since ExecutionQueue is only accessed from threads
within the same process, queue.Queue() is sufficient and more efficient.

- Eliminates unnecessary subprocess spawning per graph execution
- Removes IPC overhead for queue operations
- Prevents potential resource leaks from Manager processes
- Improves scalability for concurrent graph executions
2025-12-14 19:04:14 +05:45
Krzysztof Czerwinski
ff5c8f324b Merge branch 'master' into dev 2025-12-12 22:26:39 +09:00
Krzysztof Czerwinski
f121a22544 hotfix: update next (#11612)
Update next to `15.4.10`
2025-12-12 13:42:36 +01:00
Zamil Majdy
71157bddd7 feat(backend): add agent mode support to SmartDecisionMakerBlock with autonomous tool execution loops (#11547)
## Summary

<img width="2072" height="1836" alt="image"
src="https://github.com/user-attachments/assets/9d231a77-6309-46b9-bc11-befb5d8e9fcc"
/>

**🚀 Major Feature: Agent Mode Support**

Adds autonomous agent mode to SmartDecisionMakerBlock, enabling it to
execute tools directly in loops until tasks are completed, rather than
just yielding tool calls for external execution.

##  **Key New Features**

### 🤖 **Agent Mode with Tool Execution Loops**
- **New `agent_mode_max_iterations` parameter** controls execution
behavior:
  - `0` = Traditional mode (single LLM call, yield tool calls)
  - `1+` = Agent mode with iteration limit
  - `-1` = Infinite agent mode (loop until finished)

### 🔄 **Autonomous Tool Execution**  
- **Direct tool execution** instead of yielding for external handling
- **Multi-iteration loops** with conversation state management
- **Automatic completion detection** when LLM stops making tool calls
- **Iteration limit handling** with graceful completion messages

### 🏗️ **Proper Database Operations**
- **Replace manual execution ID generation** with proper
`upsert_execution_input`/`upsert_execution_output`
- **Real NodeExecutionEntry objects** from database results
- **Proper execution status management**: QUEUED → RUNNING →
COMPLETED/FAILED

### 🔧 **Enhanced Type Safety**
- **Pydantic models** replace TypedDict: `ToolInfo` and
`ExecutionParams`
- **Runtime validation** with better error messages
- **Improved developer experience** with IDE support

## 🔧 **Technical Implementation**

### Agent Mode Flow:
```python
# Agent mode enabled with iterations
if input_data.agent_mode_max_iterations != 0:
    async for result in self._execute_tools_agent_mode(...):
        yield result  # "conversations", "finished"
    return

# Traditional mode (existing behavior)  
# Single LLM call + yield tool calls for external execution
```

### Tool Execution with Database Operations:
```python
# Before: Manual execution IDs
tool_exec_id = f"{node_exec_id}_tool_{sink_node_id}_{len(input_data)}"

# After: Proper database operations
node_exec_result, final_input_data = await db_client.upsert_execution_input(
    node_id=sink_node_id,
    graph_exec_id=execution_params.graph_exec_id,
    input_name=input_name, 
    input_data=input_value,
)
```

### Type Safety with Pydantic:
```python
# Before: Dict access prone to errors
execution_params["user_id"]  

# After: Validated model access
execution_params.user_id  # Runtime validation + IDE support
```

## 🧪 **Comprehensive Test Coverage**

- **Agent mode execution tests** with multi-iteration scenarios
- **Database operation verification** 
- **Type safety validation**
- **Backward compatibility** for traditional mode
- **Enhanced dynamic fields tests**

## 📊 **Usage Examples**

### Traditional Mode (Existing Behavior):
```python
SmartDecisionMakerBlock.Input(
    prompt="Search for keywords",
    agent_mode_max_iterations=0  # Default
)
# → Yields tool calls for external execution
```

### Agent Mode (New Feature):
```python  
SmartDecisionMakerBlock.Input(
    prompt="Complete this task using available tools",
    agent_mode_max_iterations=5  # Max 5 iterations
)
# → Executes tools directly until task completion or iteration limit
```

### Infinite Agent Mode:
```python
SmartDecisionMakerBlock.Input(
    prompt="Analyze and process this data thoroughly", 
    agent_mode_max_iterations=-1  # No limit, run until finished
)
# → Executes tools autonomously until LLM indicates completion
```

##  **Backward Compatibility**

- **Zero breaking changes** to existing functionality
- **Traditional mode remains default** (`agent_mode_max_iterations=0`)
- **All existing tests pass**
- **Same API for tool definitions and execution**

This transforms the SmartDecisionMakerBlock from a simple tool call
generator into a powerful autonomous agent capable of complex multi-step
task execution! 🎯

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 09:58:06 +00:00
Bentlybro
152e747ea6 Merge branch 'master' into dev 2025-12-11 14:40:01 +00:00
Reinier van der Leer
4d4741d558 fix(frontend/library): Transition from empty tasks view on task init (#11600)
- Resolves #11599

### Changes 🏗️

- Manually update item counts when initiating a task from `EmptyTasks`
view
- Other improvements made while debugging

### 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] `NewAgentLibraryView` transitions to full layout when a first task
is created
- [x] `NewAgentLibraryView` transitions to full layout when a first
trigger is set up
2025-12-11 11:13:53 +00:00
Krzysztof Czerwinski
bd37fe946d feat(platform): Builder search history (#11457)
Preserve user searches in the new builder and cache search results for
more efficiency.
Search is saved, so the user can see their previous searches.

### Changes 🏗️

- Add `BuilderSearch` column&migration to save user search (with all
filters)
- Builder `db.py` now caches all search results using `@cached` and
returns paginated results, so following pages are returned much quicker
- Score and sort results
- Update models&routes
- Update frontend, so it works properly with modified endpoints
- Frontend: store `serachId` and use it for subsequent searches, so we
don't save partial searches (e.g. "b", "bl", ..., "block"). Search id is
reset when user clears the search field.
- Add clickable chips to the Suggestions builder tab
- Add `HorizontalScroll` component (chips use it)

### 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] Search works and is cached
  - [x] Search sorts results
  - [x] Searches are preserved properly

---------

Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
2025-12-10 17:32:17 +00:00
Nicholas Tindle
7ff282c908 fix(frontend): Disable single dollar sign LaTeX mode in markdown rend… (#11598)
Single dollar signs ($10, $variable) are commonly used in content and
were being incorrectly interpreted as inline LaTeX math delimiters. This
change disables that behavior while keeping double dollar sign ($$...$$)
math blocks working.
## Changes 🏗️
• Configure remarkMath plugin with singleDollarTextMath: false in
MarkdownRenderer.tsx
• Double dollar sign display math ($$...$$) continues to work as
expected
• Single dollar signs are no longer interpreted as inline math
delimiters
## 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] Verify content with dollar amounts (e.g., “$100”) renders as plain
text
-[x] Verify double dollar sign math blocks ($$x^2$$) still render as
LaTeX
-[x] Verify other markdown features (code blocks, tables, links) still
work correctly

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-10 17:10:52 +00:00
Reinier van der Leer
117bb05438 fix(frontend/library): Fix trigger UX flows in v3 library (#11589)
- Resolves #11586
- Follow-up to #11580

### Changes 🏗️

- Fix logic to include manual triggers as a possibility
- Fix input render logic to use trigger setup schema if applicable
- Fix rendering payload input for externally triggered runs
- Amend `RunAgentModal` to load preset inputs+credentials if selected
- Amend `SelectedTemplateView` to use modified input for run (if
applicable)
- Hide non-applicable buttons in `SelectedRunView` for externally
triggered runs
- Implement auto-navigation to `SelectedTriggerView` on trigger setup

### 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] Can set up manual triggers
    - [x] Navigates to trigger view after setup
  - [x] Can set up automatic triggers
  - [x] Can create templates from runs
  - [x] Can run templates
  - [x] Can run templates with modified input
2025-12-10 15:52:02 +00:00
Nicholas Tindle
979d7c3b74 feat(blocks): Add 4 new GitHub webhook trigger blocks (#11588)
I want to be able to automate some actions on social media or our
sevrver in response to actions from discord


<!-- Clearly explain the need for these changes: -->

### Changes 🏗️
Add trigger blocks for common GitHub events to enable OSS automation:
- GithubReleaseTriggerBlock: Trigger on release events (published, etc.)
- GithubStarTriggerBlock: Trigger on star events for milestone
celebrations
- GithubIssuesTriggerBlock: Trigger on issue events for
triage/notifications
- GithubDiscussionTriggerBlock: Trigger on discussion events for Q&A
sync
<!-- Concisely describe all of the changes made in this pull 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:
  <!-- Put your test plan here: -->
  - [x] Test Stars
  - [x] Test Discussions
  - [x] Test Issues
  - [x] Test Release

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-09 21:25:43 +00:00
Nicholas Tindle
95200b67f8 feat(blocks): add many new spreadsheet blocks (#11574)
<!-- Clearly explain the need for these changes: -->
We have lots we want to do with google sheets and we don't want a lack
of blocks to be a limiter so I pre-ddi a lot of blocks!

### Changes 🏗️
Adds 24 new blocks for google sheets (tested and working)
```
|-----|-------------------------------------------|----------------------------------------|
|  1  | GoogleSheetsFilterRowsBlock               | Filter rows based on column conditions |  |
|  2  | GoogleSheetsLookupRowBlock                | VLOOKUP-style row lookup               |  |
|  3  | GoogleSheetsDeleteRowsBlock               | Delete rows from a sheet               |  |
|  4  | GoogleSheetsGetColumnBlock                | Get data from a specific column        |  |
|  5  | GoogleSheetsSortBlock                     | Sort sheet data                        |  | 
|  6  | GoogleSheetsGetUniqueValuesBlock          | Get unique values from a column        |  | 
|  7  | GoogleSheetsInsertRowBlock                | Insert rows into a sheet               |  |
|  8  | GoogleSheetsAddColumnBlock                | Add a new column                       |  |
|  9  | GoogleSheetsGetRowCountBlock              | Get the number of rows                 |  |
| 10  | GoogleSheetsRemoveDuplicatesBlock         | Remove duplicate rows                  |  | 
| 11  | GoogleSheetsUpdateRowBlock                | Update an existing row                 |  |
| 12  | GoogleSheetsGetRowBlock                   | Get a specific row by index            |  |
| 13  | GoogleSheetsDeleteColumnBlock             | Delete a column                        |  |
| 14  | GoogleSheetsCreateNamedRangeBlock         | Create a named range                   |  | 
| 15  | GoogleSheetsListNamedRangesBlock          | List all named ranges                  |  | 
| 16  | GoogleSheetsAddDropdownBlock              | Add dropdown validation to cells       |  | 
| 17  | GoogleSheetsCopyToSpreadsheetBlock        | Copy sheet to another spreadsheet      |  |
| 18  | GoogleSheetsProtectRangeBlock             | Protect a range from editing           |  |
| 19  | GoogleSheetsExportCsvBlock                | Export sheet as CSV                    |  |
| 20  | GoogleSheetsImportCsvBlock                | Import CSV data                        |  |
| 21  | GoogleSheetsAddNoteBlock                  | Add notes to cells                     |  | 
| 22  | GoogleSheetsGetNotesBlock                 | Get notes from cells                   |  | 
| 23  | GoogleSheetsShareSpreadsheetBlock         | Share spreadsheet with users           |  | 
| 24  | GoogleSheetsSetPublicAccessBlock          | Set public access permissions          |  | 
```


<!-- Concisely describe all of the changes made in this pull 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:
  <!-- Put your test plan here: -->
  - [x] Tested using the attached agent 
[super test for
spreadsheets_v9.json](https://github.com/user-attachments/files/24041582/super.test.for.spreadsheets_v9.json)


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces a large suite of Google Sheets blocks for row/column ops,
filtering/sorting/lookup, CSV import/export, notes, named ranges,
protections, sheet copy, and sharing/public access, plus refactors
append to a simpler single-row append.
> 
> - **Google Sheets blocks (new)**:
> - **Data ops**: `GoogleSheetsFilterRowsBlock`,
`GoogleSheetsLookupRowBlock`, `GoogleSheetsDeleteRowsBlock`,
`GoogleSheetsGetColumnBlock`, `GoogleSheetsSortBlock`,
`GoogleSheetsGetUniqueValuesBlock`, `GoogleSheetsInsertRowBlock`,
`GoogleSheetsAddColumnBlock`, `GoogleSheetsGetRowCountBlock`,
`GoogleSheetsRemoveDuplicatesBlock`, `GoogleSheetsUpdateRowBlock`,
`GoogleSheetsGetRowBlock`, `GoogleSheetsDeleteColumnBlock`.
> - **Named ranges & validation**: `GoogleSheetsCreateNamedRangeBlock`,
`GoogleSheetsListNamedRangesBlock`, `GoogleSheetsAddDropdownBlock`.
> - **Sheet/admin**: `GoogleSheetsCopyToSpreadsheetBlock`,
`GoogleSheetsProtectRangeBlock`.
> - **CSV & notes**: `GoogleSheetsExportCsvBlock`,
`GoogleSheetsImportCsvBlock`, `GoogleSheetsAddNoteBlock`,
`GoogleSheetsGetNotesBlock`.
> - **Sharing**: `GoogleSheetsShareSpreadsheetBlock`,
`GoogleSheetsSetPublicAccessBlock`.
> - **Refactor**:
> - Rename and simplify append: `GoogleSheetsAppendRowBlock` (replaces
multi-row/dict input with single `row`), fixed insert option to
`INSERT_ROWS` and streamlined response.
> - **Utilities/Enums**:
> - Add helpers (`_column_letter_to_index`, `_index_to_column_letter`,
`_apply_filter`) and enums (`FilterOperator`, `SortOrder`, `ShareRole`,
`PublicAccessRole`).
> - Drive/Sheets service builders and file validation reused across new
blocks.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6e9e2f4024. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-12-09 17:28:22 +00:00
Abhimanyu Yadav
f8afc6044e fix(frontend): prevent file upload buttons from triggering form submission (#11576)
<!-- Clearly explain the need for these changes: -->

In the File Widget, the upload button was incorrectly behaving like a
submit button. When users clicked it, the rjsf library immediately
triggered form validation and displayed validation errors, even though
the user was only trying to upload a file.

This happened because HTML buttons inside a form default to
`type="submit"`, which triggers form submission on click. By explicitly
setting `type="button"` on all file-related buttons, we prevent them
from submitting the form while still allowing them to trigger the file
input dialog.

### Changes 🏗️

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

- Added `type="button"` attribute to the clear button in the compact
variant
- Added `type="button"` attribute to the upload button in the compact
variant
- Added `type="button"` attribute to the "Browse File" button in the
default variant

This ensures that clicking any of these buttons only triggers the
intended file selection/upload action without causing unwanted form
validation or submission.

### Checklist 📋

#### For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Tested clicking the upload button in a form with File Widget -
form should not submit or show validation errors
- [x] Tested clicking the clear button - should clear the file without
triggering form validation
- [x] Tested clicking the "Browse File" button - should open file dialog
without triggering form validation
- [x] Verified file upload functionality still works correctly after
selecting a file
2025-12-09 15:54:17 +00:00
Abhimanyu Yadav
7edf01777e fix(frontend): sync flowVersion to URL when loading graph from Library (#11585)
<!-- Clearly explain the need for these changes: -->

When opening a graph from the Library, the `flowVersion` query parameter
was not being set in the URL. This caused issues when the graph data
didn't contain an internal `graphVersion`, resulting in the builder
failing and graphs breaking when running.

The `useGetV1GetSpecificGraph` hook relies on the `flowVersion` query
parameter to fetch the correct graph version. Without it being properly
set in the URL, the graph loading logic fails when the version
information is missing from the graph data itself.

### Changes 🏗️

- Added `setQueryStates` to the `useQueryStates` hook return value in
`useFlow.ts`
- Added logic to sync `flowVersion` to the URL query parameters when a
graph is loaded
- When `graph.version` is available, it now updates the `flowVersion`
query parameter in the URL (defaults to `1` if version is undefined)

This ensures the URL stays in sync with the loaded graph's version,
preventing builder failures and execution issues.

### 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] Open a graph from the Library that doesn't have a version in the
URL
- [x] Verify that `flowVersion` is automatically added to the URL query
parameters
  - [x] Verify that the graph loads correctly and can be executed
- [x] Verify that graphs with existing `flowVersion` in URL continue to
work correctly
- [x] Verify that graphs opened from Library with version information
sync correctly
2025-12-09 15:26:45 +00:00
Ubbe
c9681f5d44 fix(frontend): library page adjustments (#11587)
## Changes 🏗️

### Adjust layout and styles on mobile 📱 

<img width="448" height="843" alt="Screenshot 2025-12-09 at 22 53 14"
src="https://github.com/user-attachments/assets/159bdf4f-e6b2-42f5-8fdf-25f8a62c62d1"
/>

### Make the sidebar cards have contextual actions

<img width="486" height="243" alt="Screenshot 2025-12-09 at 22 53 27"
src="https://github.com/user-attachments/assets/2f530168-3217-47c4-b08d-feccbb9e9152"
/>

Depending on the card type, different type of actions are shown...

### Make buttons in "About agent" card do something

<img width="344" height="346" alt="Screenshot 2025-12-09 at 22 54 01"
src="https://github.com/user-attachments/assets/47181f80-1f68-4ef1-aecc-bbadc7cc9c44"
/>

### Other

- Hide `Schedule` button for agents with trigger run type
- Adjust secondary button background colour...
- Make drawer content scrollable on mobile 

## Checklist 📋

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

Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
2025-12-09 23:17:44 +07:00
Abhimanyu Yadav
1305325813 fix(frontend): preserve button shape in credential select when content is long (#11577)
<!-- Clearly explain the need for these changes: -->

When the content inside the credential select dropdown becomes too long,
the adjacent link buttons lose their rounded shape and appear squarish.
This happens when the text stretches the container or affects the layout
of the buttons.

The issue occurs because the button's width can shrink below its
intended size when the flex container is stretched by long credential
names. By adding an explicit minimum width constraint with `!min-w-8`,
we ensure the button maintains its proper dimensions and rounded
appearance regardless of the select dropdown's content length.

### Changes 🏗️

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

- Added `!min-w-8` to the external link button's className in
`SelectCredential` component to enforce a minimum width of 2rem (8 *
0.25rem)
- This ensures the button maintains its rounded shape even when the
adjacent select dropdown contains long credential names

### 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] Tested credential select with short credential names - button
should maintain rounded shape
- [x] Tested credential select with very long credential names (e.g.,
long provider names, usernames, and hosts) - button should maintain
rounded shape
2025-12-09 15:26:22 +00:00
Abhimanyu Yadav
4f349281bd feat(frontend): switch copied graph storage from local storage to clipboard (#11578)
### Changes 🏗️

This PR migrates the copy/paste functionality for graph nodes and edges
from local storage to the Clipboard API. This change addresses storage
limitations and enables cross-tab copying.


https://github.com/user-attachments/assets/6ef55713-ca5b-4562-bb54-4c12db241d30


**Key changes:**
- Replaced `localStorage` with `navigator.clipboard` API for copy/paste
operations
- Added `CLIPBOARD_PREFIX` constant (`"autogpt-flow-data:"`) to identify
our clipboard data and prevent conflicts with other clipboard content
- Added toast notifications to provide user feedback when copying nodes
- Added error handling for clipboard read/write operations with console
error logging
- Removed dependency on `@/services/storage/local-storage` for copied
flow data
- Updated `useCopyPaste` hook to use async clipboard operations with
proper promise handling

**Benefits:**
-  Removes local storage size limitations (5-10MB)
-  Enables copying nodes between browser tabs/windows
-  Provides better user feedback through toast notifications
-  More standard approach using native browser Clipboard API

### 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] Select one or more nodes in the flow editor
  - [x] Press `Ctrl+C` (or `Cmd+C` on Mac) to copy nodes
- [x] Verify toast notification appears showing "Copied successfully"
with node count
  - [x] Press `Ctrl+V` (or `Cmd+V` on Mac) to paste nodes
  - [x] Verify nodes are pasted at viewport center with new unique IDs
  - [x] Verify edges between copied nodes are also pasted correctly
- [x] Test copying nodes in one browser tab and pasting in another tab
(should work)
- [x] Test copying non-flow data (e.g., text) and verify paste doesn't
interfere with flow editor
2025-12-09 15:26:12 +00:00
Ubbe
6c43b34dee feat(frontend): add templates/triggers to new Library page view (#11580)
## Changes 🏗️

Add Templates to the new Agent Library page:

<img width="800" height="889" alt="Screenshot 2025-12-09 at 14 10 01"
src="https://github.com/user-attachments/assets/85f0d478-f3f9-4ccf-81df-b9a7f4ae8849"
/>

- You can create a template from a run ( new action button )
- Templates are listed and can be selected on the sidebar
- When viewing a template, you can edit it, create a task or delete it

Add Triggers to the new Agent Library page:

<img width="800" height="836" alt="Screenshot 2025-12-09 at 14 10 43"
src="https://github.com/user-attachments/assets/c722f807-c72f-4a4d-8778-e36bea203f6e"
/>

- When an agent contains a trigger block, on the modal it will create a
trigger
- When there are triggers, they are listed on the sidebar
- A trigger can be viewed and edited

## 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 the new page locally
2025-12-09 18:30:13 +07:00
Zamil Majdy
c1e21d07e6 feat(platform): add execution accuracy alert system (#11562)
## Summary

<img width="1263" height="883" alt="image"
src="https://github.com/user-attachments/assets/98d4f449-1897-4019-a599-846c27df4191"
/>
<img width="398" height="190" alt="image"
src="https://github.com/user-attachments/assets/0138ac02-420d-4f96-b980-74eb41e3c968"
/>

- Add execution accuracy monitoring with moving averages and Discord
alerts
- Dashboard visualization for accuracy trends and alert detection  
- Hourly monitoring for marketplace agents (≥10 executions in 30 days)
- Generated API client integration with type-safe models

## Features
- **Moving Average Analysis**: 3-day vs 7-day comparison with
configurable thresholds
- **Discord Notifications**: Hourly alerts for accuracy drops ≥10%
- **Dashboard UI**: Real-time trends visualization with alert status
- **Type Safety**: Generated API hooks and models throughout
- **Error Handling**: Graceful OpenAI configuration handling
- **PostgreSQL Optimization**: Window functions for efficient trend
queries

## Test plan
- [x] Backend accuracy monitoring logic tested with sample data
- [x] Frontend components using generated API hooks (no manual fetch)
- [x] Discord notification integration working
- [x] Admin authentication and authorization working
- [x] All formatting and linting checks passing
- [x] Error handling for missing OpenAI configuration
- [x] Test data available with `test-accuracy-agent-001`

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-08 19:28:57 +00:00
Ubbe
aaa8dcc5a8 feat(frontend): design updates run agent modal (#11570)
## Changes 🏗️

<img width="500" height="927" alt="Screenshot 2025-12-08 at 16 30 04"
src="https://github.com/user-attachments/assets/97170302-50ae-4750-99e1-275861ee9e08"
/>

<img width="500" height="897" alt="Screenshot 2025-12-08 at 16 30 10"
src="https://github.com/user-attachments/assets/0a7ac828-ebea-40de-a19e-fbf77b0c2eb7"
/>

Update the new **Run Agent Modal** as a new view. From now on, sections
( inputs, credentials, etc... ) are enclosed. Refactor all the existing
code to be more readable and [adhere to code
conventions](https://github.com/Significant-Gravitas/AutoGPT/blob/master/autogpt_platform/frontend/CONTRIBUTING.md).

### Other improvements

- Refactor `<CredentialInputs />`:
  - to adhere to code conventions
  - to show all existing credentials for a given provider
  - to allow deletion of a given credential
  - to allow to select/switch credentials for a given task
- Run Graph Errors:
- when the run fails, show the errors nicely on the toast and link to
the builder for further debugging
- Design System:
  - secondary button colour has been updated 
  - labels size for form fields has been updated 

## 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 the app locally
  - [x] Test the above updates running agents from the new library page
2025-12-08 21:47:32 +07:00
Nicholas Tindle
c4eb7edb65 feat(platform): Improve Google Sheets/Drive integration with unified credentials (#11520)
Simplifies and improves the Google Sheets/Drive integration by merging
credentials with the file picker and using narrower OAuth scopes.

### Changes 🏗️

- Merge Google credentials and file picker into a single unified input
field for better UX
- Create spreadsheets using Drive API instead of Sheets API for proper
scope support
- Simplify Google Drive OAuth scope to only use `drive.file` (narrowest
permission needed)
- Clean up unused imports (NormalizedPickedFile)

### 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 creating a new Google Spreadsheet with
GoogleSheetsCreateSpreadsheetBlock
- [x] Test reading from existing spreadsheets with GoogleSheetsReadBlock
  - [x] Test writing to spreadsheets with GoogleSheetsWriteBlock
  - [x] Verify OAuth flow works with simplified scopes
  - [x] Verify file picker works with merged credentials field

#### 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**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Unifies Google Drive picker and credentials with auto-credentials
across backend and frontend, updates all Sheets blocks and execution to
use it, and adds Drive-based spreadsheet creation plus supporting tests
and UI fixes.
> 
> - **Backend**:
> - **Google Drive model/field**: Introduce `GoogleDriveFile` (with
`_credentials_id`) and `GoogleDriveFileField()` for unified auth+picker
(`backend/blocks/google/_drive.py`).
> - **Sheets blocks**: Replace `GoogleDrivePickerField` and explicit
credentials with `GoogleDriveFileField` across all Sheets blocks;
preserve and emit credentials for chaining; add Drive service; create
spreadsheets via Drive API then manage via Sheets API.
> - **IO block**: Add `AgentGoogleDriveFileInputBlock` providing a Drive
picker input.
> - **Execution**: Support auto-generated credentials via
`BlockSchema.get_auto_credentials_fields()`; acquire/release multiple
credential locks; pass creds by `credentials_kwarg`
(`executor/manager.py`, `data/block.py`, `util/test.py`).
> - **Tests**: Add validation tests for duplicate/unique
`auto_credentials.kwarg_name` and defaults.
> - **Frontend**:
> - **Picker**: Enhance Google Drive picker to require/use saved
platform credentials, pass `_credentials_id`, validate scopes, and
manage dialog z-index/interaction; expose `requirePlatformCredentials`.
> - **UI**: Update dialogs/CSS to keep Google picker on top and prevent
overlay interactions.
> - **Types**: Extend `GoogleDrivePickerConfig` with `auto_credentials`
and related typings.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7d25534def. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-12-05 09:44:38 -06:00
Nicholas Tindle
3f690ea7b8 fix(platform/frontend): security upgrade next from 15.4.7 to 15.4.8 (#11536)
![snyk-top-banner](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### Snyk has created this PR to fix 1 vulnerabilities in the yarn
dependencies of this project.

#### Snyk changed the following file(s):

- `autogpt_platform/frontend/package.json`


#### Note for
[zero-installs](https://yarnpkg.com/features/zero-installs) users

If you are using the Yarn feature
[zero-installs](https://yarnpkg.com/features/zero-installs) that was
introduced in Yarn V2, note that this PR does not update the
`.yarn/cache/` directory meaning this code cannot be pulled and
immediately developed on as one would expect for a zero-install project
- you will need to run `yarn` to update the contents of the
`./yarn/cache` directory.
If you are not using zero-install you can ignore this as your flow
should likely be unchanged.



<details>
<summary>⚠️ <b>Warning</b></summary>

```
Failed to update the yarn.lock, please update manually before merging.
```

</details>



#### Vulnerabilities that will be fixed with an upgrade:

|  | Issue |  
:-------------------------:|:-------------------------
![critical
severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/c.png
'critical severity') | Arbitrary Code Injection
<br/>[SNYK-JS-NEXT-14173355](https://snyk.io/vuln/SNYK-JS-NEXT-14173355)




---

> [!IMPORTANT]
>
> - Check the changes in this PR to ensure they won't cause issues with
your project.
> - Max score is 1000. Note that the real score may have changed since
the PR was raised.
> - This PR was automatically created by Snyk using the credentials of a
real user.

---

**Note:** _You are seeing this because you or someone else with access
to this repository has authorized Snyk to open fix PRs._

For more information: <img
src="https://api.segment.io/v1/pixel/track?data=eyJ3cml0ZUtleSI6InJyWmxZcEdHY2RyTHZsb0lYd0dUcVg4WkFRTnNCOUEwIiwiYW5vbnltb3VzSWQiOiJhNDQzN2JlZC0wMjYxLTRhZmMtYmQxOS1hMTUwY2RhMDE3ZDciLCJldmVudCI6IlBSIHZpZXdlZCIsInByb3BlcnRpZXMiOnsicHJJZCI6ImE0NDM3YmVkLTAyNjEtNGFmYy1iZDE5LWExNTBjZGEwMTdkNyJ9fQ=="
width="0" height="0"/>
🧐 [View latest project
report](https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr)
📜 [Customise PR
templates](https://docs.snyk.io/scan-using-snyk/pull-requests/snyk-fix-pull-or-merge-requests/customize-pr-templates?utm_source=github&utm_content=fix-pr-template)
🛠 [Adjust project
settings](https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr/settings)
📚 [Read about Snyk's upgrade
logic](https://docs.snyk.io/scan-with-snyk/snyk-open-source/manage-vulnerabilities/upgrade-package-versions-to-fix-vulnerabilities?utm_source=github&utm_content=fix-pr-template)

---

**Learn how to fix vulnerabilities with free interactive lessons:**

🦉 [Arbitrary Code
Injection](https://learn.snyk.io/lesson/insecure-deserialization/?loc&#x3D;fix-pr)

[//]: #
'snyk:metadata:{"breakingChangeRiskLevel":null,"FF_showPullRequestBreakingChanges":false,"FF_showPullRequestBreakingChangesWebSearch":false,"customTemplate":{"variablesUsed":[],"fieldsUsed":[]},"dependencies":[{"name":"next","from":"15.4.7","to":"15.4.8"}],"env":"prod","issuesToFix":["SNYK-JS-NEXT-14173355"],"prId":"a4437bed-0261-4afc-bd19-a150cda017d7","prPublicId":"a4437bed-0261-4afc-bd19-a150cda017d7","packageManager":"yarn","priorityScoreList":[null],"projectPublicId":"3d924968-0cf3-4767-9609-501fa4962856","projectUrl":"https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source=github&utm_medium=referral&page=fix-pr","prType":"fix","templateFieldSources":{"branchName":"default","commitMessage":"default","description":"default","title":"default"},"templateVariants":["updated-fix-title","pr-warning-shown"],"type":"auto","upgrade":["SNYK-JS-NEXT-14173355"],"vulns":["SNYK-JS-NEXT-14173355"],"patch":[],"isBreakingChange":false,"remediationStrategy":"vuln"}'

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Upgrades Next.js from 15.4.7 to 15.4.8 in the frontend and updates
lockfile/transitive references accordingly.
> 
> - **Dependencies**:
> - Bump `next` to `15.4.8` in `autogpt_platform/frontend/package.json`.
> - Update lockfile to align, including `@next/*` SWC binaries and
packages that peer-depend on `next` (e.g., `@sentry/nextjs`,
`@storybook/nextjs`, `@vercel/*`, `geist`, `nuqs`,
`@next/third-parties`).
> - Minor transitive tweak: `sharp` dependency `semver` updated to
`7.7.3`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e7741cbfb5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Bentlybro <Github@bentlybro.com>
Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
2025-12-05 09:44:12 -06:00
123 changed files with 12740 additions and 2895 deletions

View File

@@ -0,0 +1,108 @@
{
"action": "created",
"discussion": {
"repository_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"category": {
"id": 12345678,
"node_id": "DIC_kwDOJKSTjM4CXXXX",
"repository_id": 614765452,
"emoji": ":pray:",
"name": "Q&A",
"description": "Ask the community for help",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2023-03-16T09:21:07Z",
"slug": "q-a",
"is_answerable": true
},
"answer_html_url": null,
"answer_chosen_at": null,
"answer_chosen_by": null,
"html_url": "https://github.com/Significant-Gravitas/AutoGPT/discussions/9999",
"id": 5000000001,
"node_id": "D_kwDOJKSTjM4AYYYY",
"number": 9999,
"title": "How do I configure custom blocks?",
"user": {
"login": "curious-user",
"id": 22222222,
"node_id": "MDQ6VXNlcjIyMjIyMjIy",
"avatar_url": "https://avatars.githubusercontent.com/u/22222222?v=4",
"url": "https://api.github.com/users/curious-user",
"html_url": "https://github.com/curious-user",
"type": "User",
"site_admin": false
},
"state": "open",
"state_reason": null,
"locked": false,
"comments": 0,
"created_at": "2024-12-01T17:00:00Z",
"updated_at": "2024-12-01T17:00:00Z",
"author_association": "NONE",
"active_lock_reason": null,
"body": "## Question\n\nI'm trying to create a custom block for my specific use case. I've read the documentation but I'm not sure how to:\n\n1. Define the input/output schema\n2. Handle authentication\n3. Test my block locally\n\nCan someone point me to examples or provide guidance?\n\n## Environment\n\n- AutoGPT Platform version: latest\n- Python: 3.11",
"reactions": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/discussions/9999/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"timeline_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/discussions/9999/timeline"
},
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T17:00:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170000,
"watchers_count": 170000,
"language": "Python",
"has_discussions": true,
"forks_count": 45000,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "curious-user",
"id": 22222222,
"node_id": "MDQ6VXNlcjIyMjIyMjIy",
"avatar_url": "https://avatars.githubusercontent.com/u/22222222?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/curious-user",
"html_url": "https://github.com/curious-user",
"type": "User",
"site_admin": false
}
}

View File

@@ -0,0 +1,112 @@
{
"action": "opened",
"issue": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345",
"repository_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"labels_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/labels{/name}",
"comments_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/comments",
"events_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/events",
"html_url": "https://github.com/Significant-Gravitas/AutoGPT/issues/12345",
"id": 2000000001,
"node_id": "I_kwDOJKSTjM5wXXXX",
"number": 12345,
"title": "Bug: Application crashes when processing large files",
"user": {
"login": "bug-reporter",
"id": 11111111,
"node_id": "MDQ6VXNlcjExMTExMTEx",
"avatar_url": "https://avatars.githubusercontent.com/u/11111111?v=4",
"url": "https://api.github.com/users/bug-reporter",
"html_url": "https://github.com/bug-reporter",
"type": "User",
"site_admin": false
},
"labels": [
{
"id": 5272676214,
"node_id": "LA_kwDOJKSTjM8AAAABOkandg",
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/labels/bug",
"name": "bug",
"color": "d73a4a",
"default": true,
"description": "Something isn't working"
}
],
"state": "open",
"locked": false,
"assignee": null,
"assignees": [],
"milestone": null,
"comments": 0,
"created_at": "2024-12-01T16:00:00Z",
"updated_at": "2024-12-01T16:00:00Z",
"closed_at": null,
"author_association": "NONE",
"active_lock_reason": null,
"body": "## Description\n\nWhen I try to process a file larger than 100MB, the application crashes with an out of memory error.\n\n## Steps to Reproduce\n\n1. Open the application\n2. Select a file larger than 100MB\n3. Click 'Process'\n4. Application crashes\n\n## Expected Behavior\n\nThe application should handle large files gracefully.\n\n## Environment\n\n- OS: Ubuntu 22.04\n- Python: 3.11\n- AutoGPT Version: 1.0.0",
"reactions": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"timeline_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/timeline",
"state_reason": null
},
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T16:00:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170000,
"watchers_count": 170000,
"language": "Python",
"forks_count": 45000,
"open_issues_count": 190,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "bug-reporter",
"id": 11111111,
"node_id": "MDQ6VXNlcjExMTExMTEx",
"avatar_url": "https://avatars.githubusercontent.com/u/11111111?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/bug-reporter",
"html_url": "https://github.com/bug-reporter",
"type": "User",
"site_admin": false
}
}

View File

@@ -0,0 +1,97 @@
{
"action": "published",
"release": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/releases/123456789",
"assets_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/releases/123456789/assets",
"upload_url": "https://uploads.github.com/repos/Significant-Gravitas/AutoGPT/releases/123456789/assets{?name,label}",
"html_url": "https://github.com/Significant-Gravitas/AutoGPT/releases/tag/v1.0.0",
"id": 123456789,
"author": {
"login": "ntindle",
"id": 12345678,
"node_id": "MDQ6VXNlcjEyMzQ1Njc4",
"avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/ntindle",
"html_url": "https://github.com/ntindle",
"type": "User",
"site_admin": false
},
"node_id": "RE_kwDOJKSTjM4HWwAA",
"tag_name": "v1.0.0",
"target_commitish": "master",
"name": "AutoGPT Platform v1.0.0",
"draft": false,
"prerelease": false,
"created_at": "2024-12-01T10:00:00Z",
"published_at": "2024-12-01T12:00:00Z",
"assets": [
{
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/releases/assets/987654321",
"id": 987654321,
"node_id": "RA_kwDOJKSTjM4HWwBB",
"name": "autogpt-v1.0.0.zip",
"label": "Release Package",
"content_type": "application/zip",
"state": "uploaded",
"size": 52428800,
"download_count": 0,
"created_at": "2024-12-01T11:30:00Z",
"updated_at": "2024-12-01T11:35:00Z",
"browser_download_url": "https://github.com/Significant-Gravitas/AutoGPT/releases/download/v1.0.0/autogpt-v1.0.0.zip"
}
],
"tarball_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/tarball/v1.0.0",
"zipball_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/zipball/v1.0.0",
"body": "## What's New\n\n- Feature 1: Amazing new capability\n- Feature 2: Performance improvements\n- Bug fixes and stability improvements\n\n## Breaking Changes\n\nNone\n\n## Contributors\n\nThanks to all our contributors!"
},
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T12:00:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170000,
"watchers_count": 170000,
"language": "Python",
"forks_count": 45000,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "ntindle",
"id": 12345678,
"node_id": "MDQ6VXNlcjEyMzQ1Njc4",
"avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/ntindle",
"html_url": "https://github.com/ntindle",
"type": "User",
"site_admin": false
}
}

View File

@@ -0,0 +1,53 @@
{
"action": "created",
"starred_at": "2024-12-01T15:30:00Z",
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T15:30:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170001,
"watchers_count": 170001,
"language": "Python",
"forks_count": 45000,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "awesome-contributor",
"id": 98765432,
"node_id": "MDQ6VXNlcjk4NzY1NDMy",
"avatar_url": "https://avatars.githubusercontent.com/u/98765432?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/awesome-contributor",
"html_url": "https://github.com/awesome-contributor",
"type": "User",
"site_admin": false
}
}

View File

@@ -159,3 +159,391 @@ class GithubPullRequestTriggerBlock(GitHubTriggerBase, Block):
# --8<-- [end:GithubTriggerExample]
class GithubStarTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub star events - useful for milestone celebrations."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "star.created.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#star
"""
created: bool = False
deleted: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The star events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The star event that triggered the webhook ('created' or 'deleted')"
)
starred_at: str = SchemaField(
description="ISO timestamp when the repo was starred (empty if deleted)"
)
stargazers_count: int = SchemaField(
description="Current number of stars on the repository"
)
repository_name: str = SchemaField(
description="Full name of the repository (owner/repo)"
)
repository_url: str = SchemaField(description="URL to the repository")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="551e0a35-100b-49b7-89b8-3031322239b6",
description="This block triggers on GitHub star events. "
"Useful for celebrating milestones (e.g., 1k, 10k stars) or tracking engagement.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubStarTriggerBlock.Input,
output_schema=GithubStarTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="star.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"created": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("starred_at", example_payload.get("starred_at", "")),
("stargazers_count", example_payload["repository"]["stargazers_count"]),
("repository_name", example_payload["repository"]["full_name"]),
("repository_url", example_payload["repository"]["html_url"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
yield "event", input_data.payload["action"]
yield "starred_at", input_data.payload.get("starred_at", "")
yield "stargazers_count", input_data.payload["repository"]["stargazers_count"]
yield "repository_name", input_data.payload["repository"]["full_name"]
yield "repository_url", input_data.payload["repository"]["html_url"]
class GithubReleaseTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub release events - ideal for announcing new versions."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "release.published.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#release
"""
published: bool = False
unpublished: bool = False
created: bool = False
edited: bool = False
deleted: bool = False
prereleased: bool = False
released: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The release events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The release event that triggered the webhook (e.g., 'published')"
)
release: dict = SchemaField(description="The full release object")
release_url: str = SchemaField(description="URL to the release page")
tag_name: str = SchemaField(description="The release tag name (e.g., 'v1.0.0')")
release_name: str = SchemaField(description="Human-readable release name")
body: str = SchemaField(description="Release notes/description")
prerelease: bool = SchemaField(description="Whether this is a prerelease")
draft: bool = SchemaField(description="Whether this is a draft release")
assets: list = SchemaField(description="List of release assets/files")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="2052dd1b-74e1-46ac-9c87-c7a0e057b60b",
description="This block triggers on GitHub release events. "
"Perfect for automating announcements to Discord, Twitter, or other platforms.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubReleaseTriggerBlock.Input,
output_schema=GithubReleaseTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="release.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"published": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("release", example_payload["release"]),
("release_url", example_payload["release"]["html_url"]),
("tag_name", example_payload["release"]["tag_name"]),
("release_name", example_payload["release"]["name"]),
("body", example_payload["release"]["body"]),
("prerelease", example_payload["release"]["prerelease"]),
("draft", example_payload["release"]["draft"]),
("assets", example_payload["release"]["assets"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
release = input_data.payload["release"]
yield "event", input_data.payload["action"]
yield "release", release
yield "release_url", release["html_url"]
yield "tag_name", release["tag_name"]
yield "release_name", release.get("name", "")
yield "body", release.get("body", "")
yield "prerelease", release["prerelease"]
yield "draft", release["draft"]
yield "assets", release["assets"]
class GithubIssuesTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub issues events - great for triage and notifications."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "issues.opened.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#issues
"""
opened: bool = False
edited: bool = False
deleted: bool = False
closed: bool = False
reopened: bool = False
assigned: bool = False
unassigned: bool = False
labeled: bool = False
unlabeled: bool = False
locked: bool = False
unlocked: bool = False
transferred: bool = False
milestoned: bool = False
demilestoned: bool = False
pinned: bool = False
unpinned: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The issue events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The issue event that triggered the webhook (e.g., 'opened')"
)
number: int = SchemaField(description="The issue number")
issue: dict = SchemaField(description="The full issue object")
issue_url: str = SchemaField(description="URL to the issue")
issue_title: str = SchemaField(description="The issue title")
issue_body: str = SchemaField(description="The issue body/description")
labels: list = SchemaField(description="List of labels on the issue")
assignees: list = SchemaField(description="List of assignees")
state: str = SchemaField(description="Issue state ('open' or 'closed')")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="b2605464-e486-4bf4-aad3-d8a213c8a48a",
description="This block triggers on GitHub issues events. "
"Useful for automated triage, notifications, and welcoming first-time contributors.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubIssuesTriggerBlock.Input,
output_schema=GithubIssuesTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="issues.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"opened": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("number", example_payload["issue"]["number"]),
("issue", example_payload["issue"]),
("issue_url", example_payload["issue"]["html_url"]),
("issue_title", example_payload["issue"]["title"]),
("issue_body", example_payload["issue"]["body"]),
("labels", example_payload["issue"]["labels"]),
("assignees", example_payload["issue"]["assignees"]),
("state", example_payload["issue"]["state"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
issue = input_data.payload["issue"]
yield "event", input_data.payload["action"]
yield "number", issue["number"]
yield "issue", issue
yield "issue_url", issue["html_url"]
yield "issue_title", issue["title"]
yield "issue_body", issue.get("body") or ""
yield "labels", issue["labels"]
yield "assignees", issue["assignees"]
yield "state", issue["state"]
class GithubDiscussionTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub discussion events - perfect for community Q&A sync."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "discussion.created.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#discussion
"""
created: bool = False
edited: bool = False
deleted: bool = False
answered: bool = False
unanswered: bool = False
labeled: bool = False
unlabeled: bool = False
locked: bool = False
unlocked: bool = False
category_changed: bool = False
transferred: bool = False
pinned: bool = False
unpinned: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The discussion events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The discussion event that triggered the webhook"
)
number: int = SchemaField(description="The discussion number")
discussion: dict = SchemaField(description="The full discussion object")
discussion_url: str = SchemaField(description="URL to the discussion")
title: str = SchemaField(description="The discussion title")
body: str = SchemaField(description="The discussion body")
category: dict = SchemaField(description="The discussion category object")
category_name: str = SchemaField(description="Name of the category")
state: str = SchemaField(description="Discussion state")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="87f847b3-d81a-424e-8e89-acadb5c9d52b",
description="This block triggers on GitHub Discussions events. "
"Great for syncing Q&A to Discord or auto-responding to common questions. "
"Note: Discussions must be enabled on the repository.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubDiscussionTriggerBlock.Input,
output_schema=GithubDiscussionTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="discussion.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"created": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("number", example_payload["discussion"]["number"]),
("discussion", example_payload["discussion"]),
("discussion_url", example_payload["discussion"]["html_url"]),
("title", example_payload["discussion"]["title"]),
("body", example_payload["discussion"]["body"]),
("category", example_payload["discussion"]["category"]),
("category_name", example_payload["discussion"]["category"]["name"]),
("state", example_payload["discussion"]["state"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
discussion = input_data.payload["discussion"]
yield "event", input_data.payload["action"]
yield "number", discussion["number"]
yield "discussion", discussion
yield "discussion_url", discussion["html_url"]
yield "title", discussion["title"]
yield "body", discussion.get("body") or ""
yield "category", discussion["category"]
yield "category_name", discussion["category"]["name"]
yield "state", discussion["state"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
import logging
import re
from collections import Counter
from concurrent.futures import Future
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
import backend.blocks.llm as llm
from backend.blocks.agent import AgentExecutorBlock
from backend.data.block import (
@@ -20,16 +23,41 @@ from backend.data.dynamic_fields import (
is_dynamic_field,
is_tool_pin,
)
from backend.data.execution import ExecutionContext
from backend.data.model import NodeExecutionStats, SchemaField
from backend.util import json
from backend.util.clients import get_database_manager_async_client
from backend.util.prompt import MAIN_OBJECTIVE_PREFIX
if TYPE_CHECKING:
from backend.data.graph import Link, Node
from backend.executor.manager import ExecutionProcessor
logger = logging.getLogger(__name__)
class ToolInfo(BaseModel):
"""Processed tool call information."""
tool_call: Any # The original tool call object from LLM response
tool_name: str # The function name
tool_def: dict[str, Any] # The tool definition from tool_functions
input_data: dict[str, Any] # Processed input data ready for tool execution
field_mapping: dict[str, str] # Field name mapping for the tool
class ExecutionParams(BaseModel):
"""Tool execution parameters."""
user_id: str
graph_id: str
node_id: str
graph_version: int
graph_exec_id: str
node_exec_id: str
execution_context: "ExecutionContext"
def _get_tool_requests(entry: dict[str, Any]) -> list[str]:
"""
Return a list of tool_call_ids if the entry is a tool request.
@@ -105,6 +133,50 @@ def _create_tool_response(call_id: str, output: Any) -> dict[str, Any]:
return {"role": "tool", "tool_call_id": call_id, "content": content}
def _combine_tool_responses(tool_outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Combine multiple Anthropic tool responses into a single user message.
For non-Anthropic formats, returns the original list unchanged.
"""
if len(tool_outputs) <= 1:
return tool_outputs
# Anthropic responses have role="user", type="message", and content is a list with tool_result items
anthropic_responses = [
output
for output in tool_outputs
if (
output.get("role") == "user"
and output.get("type") == "message"
and isinstance(output.get("content"), list)
and any(
item.get("type") == "tool_result"
for item in output.get("content", [])
if isinstance(item, dict)
)
)
]
if len(anthropic_responses) > 1:
combined_content = [
item for response in anthropic_responses for item in response["content"]
]
combined_response = {
"role": "user",
"type": "message",
"content": combined_content,
}
non_anthropic_responses = [
output for output in tool_outputs if output not in anthropic_responses
]
return [combined_response] + non_anthropic_responses
return tool_outputs
def _convert_raw_response_to_dict(raw_response: Any) -> dict[str, Any]:
"""
Safely convert raw_response to dictionary format for conversation history.
@@ -204,6 +276,17 @@ class SmartDecisionMakerBlock(Block):
default="localhost:11434",
description="Ollama host for local models",
)
agent_mode_max_iterations: int = SchemaField(
title="Agent Mode Max Iterations",
description="Maximum iterations for agent mode. 0 = traditional mode (single LLM call, yield tool calls for external execution), -1 = infinite agent mode (loop until finished), 1+ = agent mode with max iterations limit.",
advanced=True,
default=0,
)
conversation_compaction: bool = SchemaField(
default=True,
title="Context window auto-compaction",
description="Automatically compact the context window once it hits the limit",
)
@classmethod
def get_missing_links(cls, data: BlockInput, links: list["Link"]) -> set[str]:
@@ -506,6 +589,7 @@ class SmartDecisionMakerBlock(Block):
Returns the response if successful, raises ValueError if validation fails.
"""
resp = await llm.llm_call(
compress_prompt_to_fit=input_data.conversation_compaction,
credentials=credentials,
llm_model=input_data.model,
prompt=current_prompt,
@@ -593,6 +677,291 @@ class SmartDecisionMakerBlock(Block):
return resp
def _process_tool_calls(
self, response, tool_functions: list[dict[str, Any]]
) -> list[ToolInfo]:
"""Process tool calls and extract tool definitions, arguments, and input data.
Returns a list of tool info dicts with:
- tool_call: The original tool call object
- tool_name: The function name
- tool_def: The tool definition from tool_functions
- input_data: Processed input data dict (includes None values)
- field_mapping: Field name mapping for the tool
"""
if not response.tool_calls:
return []
processed_tools = []
for tool_call in response.tool_calls:
tool_name = tool_call.function.name
tool_args = json.loads(tool_call.function.arguments)
tool_def = next(
(
tool
for tool in tool_functions
if tool["function"]["name"] == tool_name
),
None,
)
if not tool_def:
if len(tool_functions) == 1:
tool_def = tool_functions[0]
else:
continue
# Build input data for the tool
input_data = {}
field_mapping = tool_def["function"].get("_field_mapping", {})
if "function" in tool_def and "parameters" in tool_def["function"]:
expected_args = tool_def["function"]["parameters"].get("properties", {})
for clean_arg_name in expected_args:
original_field_name = field_mapping.get(
clean_arg_name, clean_arg_name
)
arg_value = tool_args.get(clean_arg_name)
# Include all expected parameters, even if None (for backward compatibility with tests)
input_data[original_field_name] = arg_value
processed_tools.append(
ToolInfo(
tool_call=tool_call,
tool_name=tool_name,
tool_def=tool_def,
input_data=input_data,
field_mapping=field_mapping,
)
)
return processed_tools
def _update_conversation(
self, prompt: list[dict], response, tool_outputs: list | None = None
):
"""Update conversation history with response and tool outputs."""
# Don't add separate reasoning message with tool calls (breaks Anthropic's tool_use->tool_result pairing)
assistant_message = _convert_raw_response_to_dict(response.raw_response)
has_tool_calls = isinstance(assistant_message.get("content"), list) and any(
item.get("type") == "tool_use"
for item in assistant_message.get("content", [])
)
if response.reasoning and not has_tool_calls:
prompt.append(
{"role": "assistant", "content": f"[Reasoning]: {response.reasoning}"}
)
prompt.append(assistant_message)
if tool_outputs:
prompt.extend(tool_outputs)
async def _execute_single_tool_with_manager(
self,
tool_info: ToolInfo,
execution_params: ExecutionParams,
execution_processor: "ExecutionProcessor",
) -> dict:
"""Execute a single tool using the execution manager for proper integration."""
# Lazy imports to avoid circular dependencies
from backend.data.execution import NodeExecutionEntry
tool_call = tool_info.tool_call
tool_def = tool_info.tool_def
raw_input_data = tool_info.input_data
# Get sink node and field mapping
sink_node_id = tool_def["function"]["_sink_node_id"]
# Use proper database operations for tool execution
db_client = get_database_manager_async_client()
# Get target node
target_node = await db_client.get_node(sink_node_id)
if not target_node:
raise ValueError(f"Target node {sink_node_id} not found")
# Create proper node execution using upsert_execution_input
node_exec_result = None
final_input_data = None
# Add all inputs to the execution
if not raw_input_data:
raise ValueError(f"Tool call has no input data: {tool_call}")
for input_name, input_value in raw_input_data.items():
node_exec_result, final_input_data = await db_client.upsert_execution_input(
node_id=sink_node_id,
graph_exec_id=execution_params.graph_exec_id,
input_name=input_name,
input_data=input_value,
)
assert node_exec_result is not None, "node_exec_result should not be None"
# Create NodeExecutionEntry for execution manager
node_exec_entry = NodeExecutionEntry(
user_id=execution_params.user_id,
graph_exec_id=execution_params.graph_exec_id,
graph_id=execution_params.graph_id,
graph_version=execution_params.graph_version,
node_exec_id=node_exec_result.node_exec_id,
node_id=sink_node_id,
block_id=target_node.block_id,
inputs=final_input_data or {},
execution_context=execution_params.execution_context,
)
# Use the execution manager to execute the tool node
try:
# Get NodeExecutionProgress from the execution manager's running nodes
node_exec_progress = execution_processor.running_node_execution[
sink_node_id
]
# Use the execution manager's own graph stats
graph_stats_pair = (
execution_processor.execution_stats,
execution_processor.execution_stats_lock,
)
# Create a completed future for the task tracking system
node_exec_future = Future()
node_exec_progress.add_task(
node_exec_id=node_exec_result.node_exec_id,
task=node_exec_future,
)
# Execute the node directly since we're in the SmartDecisionMaker context
node_exec_future.set_result(
await execution_processor.on_node_execution(
node_exec=node_exec_entry,
node_exec_progress=node_exec_progress,
nodes_input_masks=None,
graph_stats_pair=graph_stats_pair,
)
)
# Get outputs from database after execution completes using database manager client
node_outputs = await db_client.get_execution_outputs_by_node_exec_id(
node_exec_result.node_exec_id
)
# Create tool response
tool_response_content = (
json.dumps(node_outputs)
if node_outputs
else "Tool executed successfully"
)
return _create_tool_response(tool_call.id, tool_response_content)
except Exception as e:
logger.error(f"Tool execution with manager failed: {e}")
# Return error response
return _create_tool_response(
tool_call.id, f"Tool execution failed: {str(e)}"
)
async def _execute_tools_agent_mode(
self,
input_data,
credentials,
tool_functions: list[dict[str, Any]],
prompt: list[dict],
graph_exec_id: str,
node_id: str,
node_exec_id: str,
user_id: str,
graph_id: str,
graph_version: int,
execution_context: ExecutionContext,
execution_processor: "ExecutionProcessor",
):
"""Execute tools in agent mode with a loop until finished."""
max_iterations = input_data.agent_mode_max_iterations
iteration = 0
# Execution parameters for tool execution
execution_params = ExecutionParams(
user_id=user_id,
graph_id=graph_id,
node_id=node_id,
graph_version=graph_version,
graph_exec_id=graph_exec_id,
node_exec_id=node_exec_id,
execution_context=execution_context,
)
current_prompt = list(prompt)
while max_iterations < 0 or iteration < max_iterations:
iteration += 1
logger.debug(f"Agent mode iteration {iteration}")
# Prepare prompt for this iteration
iteration_prompt = list(current_prompt)
# On the last iteration, add a special system message to encourage completion
if max_iterations > 0 and iteration == max_iterations:
last_iteration_message = {
"role": "system",
"content": f"{MAIN_OBJECTIVE_PREFIX}This is your last iteration ({iteration}/{max_iterations}). "
"Try to complete the task with the information you have. If you cannot fully complete it, "
"provide a summary of what you've accomplished and what remains to be done. "
"Prefer finishing with a clear response rather than making additional tool calls.",
}
iteration_prompt.append(last_iteration_message)
# Get LLM response
try:
response = await self._attempt_llm_call_with_validation(
credentials, input_data, iteration_prompt, tool_functions
)
except Exception as e:
yield "error", f"LLM call failed in agent mode iteration {iteration}: {str(e)}"
return
# Process tool calls
processed_tools = self._process_tool_calls(response, tool_functions)
# If no tool calls, we're done
if not processed_tools:
yield "finished", response.response
self._update_conversation(current_prompt, response)
yield "conversations", current_prompt
return
# Execute tools and collect responses
tool_outputs = []
for tool_info in processed_tools:
try:
tool_response = await self._execute_single_tool_with_manager(
tool_info, execution_params, execution_processor
)
tool_outputs.append(tool_response)
except Exception as e:
logger.error(f"Tool execution failed: {e}")
# Create error response for the tool
error_response = _create_tool_response(
tool_info.tool_call.id, f"Error: {str(e)}"
)
tool_outputs.append(error_response)
tool_outputs = _combine_tool_responses(tool_outputs)
self._update_conversation(current_prompt, response, tool_outputs)
# Yield intermediate conversation state
yield "conversations", current_prompt
# If we reach max iterations, yield the current state
if max_iterations < 0:
yield "finished", f"Agent mode completed after {iteration} iterations"
else:
yield "finished", f"Agent mode completed after {max_iterations} iterations (limit reached)"
yield "conversations", current_prompt
async def run(
self,
input_data: Input,
@@ -603,8 +972,12 @@ class SmartDecisionMakerBlock(Block):
graph_exec_id: str,
node_exec_id: str,
user_id: str,
graph_version: int,
execution_context: ExecutionContext,
execution_processor: "ExecutionProcessor",
**kwargs,
) -> BlockOutput:
tool_functions = await self._create_tool_node_signatures(node_id)
yield "tool_functions", json.dumps(tool_functions)
@@ -648,24 +1021,52 @@ class SmartDecisionMakerBlock(Block):
input_data.prompt = llm.fmt.format_string(input_data.prompt, values)
input_data.sys_prompt = llm.fmt.format_string(input_data.sys_prompt, values)
prefix = "[Main Objective Prompt]: "
if input_data.sys_prompt and not any(
p["role"] == "system" and p["content"].startswith(prefix) for p in prompt
p["role"] == "system" and p["content"].startswith(MAIN_OBJECTIVE_PREFIX)
for p in prompt
):
prompt.append({"role": "system", "content": prefix + input_data.sys_prompt})
prompt.append(
{
"role": "system",
"content": MAIN_OBJECTIVE_PREFIX + input_data.sys_prompt,
}
)
if input_data.prompt and not any(
p["role"] == "user" and p["content"].startswith(prefix) for p in prompt
p["role"] == "user" and p["content"].startswith(MAIN_OBJECTIVE_PREFIX)
for p in prompt
):
prompt.append({"role": "user", "content": prefix + input_data.prompt})
prompt.append(
{"role": "user", "content": MAIN_OBJECTIVE_PREFIX + input_data.prompt}
)
# Execute tools based on the selected mode
if input_data.agent_mode_max_iterations != 0:
# In agent mode, execute tools directly in a loop until finished
async for result in self._execute_tools_agent_mode(
input_data=input_data,
credentials=credentials,
tool_functions=tool_functions,
prompt=prompt,
graph_exec_id=graph_exec_id,
node_id=node_id,
node_exec_id=node_exec_id,
user_id=user_id,
graph_id=graph_id,
graph_version=graph_version,
execution_context=execution_context,
execution_processor=execution_processor,
):
yield result
return
# One-off mode: single LLM call and yield tool calls for external execution
current_prompt = list(prompt)
max_attempts = max(1, int(input_data.retry))
response = None
last_error = None
for attempt in range(max_attempts):
for _ in range(max_attempts):
try:
response = await self._attempt_llm_call_with_validation(
credentials, input_data, current_prompt, tool_functions

View File

@@ -1,7 +1,11 @@
import logging
import threading
from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.data.execution import ExecutionContext
from backend.data.model import ProviderName, User
from backend.server.model import CreateGraph
from backend.server.rest_api import AgentServer
@@ -17,10 +21,10 @@ async def create_graph(s: SpinTestServer, g, u: User):
async def create_credentials(s: SpinTestServer, u: User):
import backend.blocks.llm as llm
import backend.blocks.llm as llm_module
provider = ProviderName.OPENAI
credentials = llm.TEST_CREDENTIALS
credentials = llm_module.TEST_CREDENTIALS
return await s.agent_server.test_create_credentials(u.id, provider, credentials)
@@ -196,8 +200,6 @@ async def test_smart_decision_maker_function_signature(server: SpinTestServer):
@pytest.mark.asyncio
async def test_smart_decision_maker_tracks_llm_stats():
"""Test that SmartDecisionMakerBlock correctly tracks LLM usage stats."""
from unittest.mock import MagicMock, patch
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
@@ -216,7 +218,6 @@ async def test_smart_decision_maker_tracks_llm_stats():
}
# Mock the _create_tool_node_signatures method to avoid database calls
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
@@ -234,10 +235,19 @@ async def test_smart_decision_maker_tracks_llm_stats():
prompt="Should I continue with this task?",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Execute the block
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -246,6 +256,9 @@ async def test_smart_decision_maker_tracks_llm_stats():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -263,8 +276,6 @@ async def test_smart_decision_maker_tracks_llm_stats():
@pytest.mark.asyncio
async def test_smart_decision_maker_parameter_validation():
"""Test that SmartDecisionMakerBlock correctly validates tool call parameters."""
from unittest.mock import MagicMock, patch
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
@@ -311,8 +322,6 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_with_typo.reasoning = None
mock_response_with_typo.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -329,8 +338,17 @@ async def test_smart_decision_maker_parameter_validation():
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
retry=2, # Set retry to 2 for testing
agent_mode_max_iterations=0,
)
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
# Should raise ValueError after retries due to typo'd parameter name
with pytest.raises(ValueError) as exc_info:
outputs = {}
@@ -342,6 +360,9 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -368,8 +389,6 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_missing_required.reasoning = None
mock_response_missing_required.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -385,8 +404,17 @@ async def test_smart_decision_maker_parameter_validation():
prompt="Search for keywords",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
# Should raise ValueError due to missing required parameter
with pytest.raises(ValueError) as exc_info:
outputs = {}
@@ -398,6 +426,9 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -418,8 +449,6 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_valid.reasoning = None
mock_response_valid.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -435,10 +464,19 @@ async def test_smart_decision_maker_parameter_validation():
prompt="Search for keywords",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Should succeed - optional parameter missing is OK
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -447,6 +485,9 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -472,8 +513,6 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_all_params.reasoning = None
mock_response_all_params.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -489,10 +528,19 @@ async def test_smart_decision_maker_parameter_validation():
prompt="Search for keywords",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Should succeed with all parameters
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -501,6 +549,9 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -513,8 +564,6 @@ async def test_smart_decision_maker_parameter_validation():
@pytest.mark.asyncio
async def test_smart_decision_maker_raw_response_conversion():
"""Test that SmartDecisionMaker correctly handles different raw_response types with retry mechanism."""
from unittest.mock import MagicMock, patch
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
@@ -584,7 +633,6 @@ async def test_smart_decision_maker_raw_response_conversion():
)
# Mock llm_call to return different responses on different calls
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call", new_callable=AsyncMock
@@ -603,10 +651,19 @@ async def test_smart_decision_maker_raw_response_conversion():
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
retry=2,
agent_mode_max_iterations=0,
)
# Should succeed after retry, demonstrating our helper function works
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -615,6 +672,9 @@ async def test_smart_decision_maker_raw_response_conversion():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -650,8 +710,6 @@ async def test_smart_decision_maker_raw_response_conversion():
"I'll help you with that." # Ollama returns string
)
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -666,9 +724,18 @@ async def test_smart_decision_maker_raw_response_conversion():
prompt="Simple prompt",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -677,6 +744,9 @@ async def test_smart_decision_maker_raw_response_conversion():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -696,8 +766,6 @@ async def test_smart_decision_maker_raw_response_conversion():
"content": "Test response",
} # Dict format
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -712,6 +780,160 @@ async def test_smart_decision_maker_raw_response_conversion():
prompt="Another test",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
graph_id="test-graph-id",
node_id="test-node-id",
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
assert "finished" in outputs
assert outputs["finished"] == "Test response"
@pytest.mark.asyncio
async def test_smart_decision_maker_agent_mode():
"""Test that agent mode executes tools directly and loops until finished."""
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
block = SmartDecisionMakerBlock()
# Mock tool call that requires multiple iterations
mock_tool_call_1 = MagicMock()
mock_tool_call_1.id = "call_1"
mock_tool_call_1.function.name = "search_keywords"
mock_tool_call_1.function.arguments = (
'{"query": "test", "max_keyword_difficulty": 50}'
)
mock_response_1 = MagicMock()
mock_response_1.response = None
mock_response_1.tool_calls = [mock_tool_call_1]
mock_response_1.prompt_tokens = 50
mock_response_1.completion_tokens = 25
mock_response_1.reasoning = "Using search tool"
mock_response_1.raw_response = {
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1", "type": "function"}],
}
# Final response with no tool calls (finished)
mock_response_2 = MagicMock()
mock_response_2.response = "Task completed successfully"
mock_response_2.tool_calls = []
mock_response_2.prompt_tokens = 30
mock_response_2.completion_tokens = 15
mock_response_2.reasoning = None
mock_response_2.raw_response = {
"role": "assistant",
"content": "Task completed successfully",
}
# Mock the LLM call to return different responses on each iteration
llm_call_mock = AsyncMock()
llm_call_mock.side_effect = [mock_response_1, mock_response_2]
# Mock tool node signatures
mock_tool_signatures = [
{
"type": "function",
"function": {
"name": "search_keywords",
"_sink_node_id": "test-sink-node-id",
"_field_mapping": {},
"parameters": {
"properties": {
"query": {"type": "string"},
"max_keyword_difficulty": {"type": "integer"},
},
"required": ["query", "max_keyword_difficulty"],
},
},
}
]
# Mock database and execution components
mock_db_client = AsyncMock()
mock_node = MagicMock()
mock_node.block_id = "test-block-id"
mock_db_client.get_node.return_value = mock_node
# Mock upsert_execution_input to return proper NodeExecutionResult and input data
mock_node_exec_result = MagicMock()
mock_node_exec_result.node_exec_id = "test-tool-exec-id"
mock_input_data = {"query": "test", "max_keyword_difficulty": 50}
mock_db_client.upsert_execution_input.return_value = (
mock_node_exec_result,
mock_input_data,
)
# No longer need mock_execute_node since we use execution_processor.on_node_execution
with patch("backend.blocks.llm.llm_call", llm_call_mock), patch.object(
block, "_create_tool_node_signatures", return_value=mock_tool_signatures
), patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
return_value=mock_db_client,
), patch(
"backend.executor.manager.async_update_node_execution_status",
new_callable=AsyncMock,
), patch(
"backend.integrations.creds_manager.IntegrationCredentialsManager"
):
# Create a mock execution context
mock_execution_context = ExecutionContext(
safe_mode=False,
)
# Create a mock execution processor for agent mode tests
mock_execution_processor = AsyncMock()
# Configure the execution processor mock with required attributes
mock_execution_processor.running_node_execution = defaultdict(MagicMock)
mock_execution_processor.execution_stats = MagicMock()
mock_execution_processor.execution_stats_lock = threading.Lock()
# Mock the on_node_execution method to return successful stats
mock_node_stats = MagicMock()
mock_node_stats.error = None # No error
mock_execution_processor.on_node_execution = AsyncMock(
return_value=mock_node_stats
)
# Mock the get_execution_outputs_by_node_exec_id method
mock_db_client.get_execution_outputs_by_node_exec_id.return_value = {
"result": {"status": "success", "data": "search completed"}
}
# Test agent mode with max_iterations = 3
input_data = SmartDecisionMakerBlock.Input(
prompt="Complete this task using tools",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=3, # Enable agent mode with 3 max iterations
)
outputs = {}
@@ -723,8 +945,115 @@ async def test_smart_decision_maker_raw_response_conversion():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
# Verify agent mode behavior
assert "tool_functions" in outputs # tool_functions is yielded in both modes
assert "finished" in outputs
assert outputs["finished"] == "Test response"
assert outputs["finished"] == "Task completed successfully"
assert "conversations" in outputs
# Verify the conversation includes tool responses
conversations = outputs["conversations"]
assert len(conversations) > 2 # Should have multiple conversation entries
# Verify LLM was called twice (once for tool call, once for finish)
assert llm_call_mock.call_count == 2
# Verify tool was executed via execution processor
assert mock_execution_processor.on_node_execution.call_count == 1
@pytest.mark.asyncio
async def test_smart_decision_maker_traditional_mode_default():
"""Test that default behavior (agent_mode_max_iterations=0) works as traditional mode."""
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
block = SmartDecisionMakerBlock()
# Mock tool call
mock_tool_call = MagicMock()
mock_tool_call.function.name = "search_keywords"
mock_tool_call.function.arguments = (
'{"query": "test", "max_keyword_difficulty": 50}'
)
mock_response = MagicMock()
mock_response.response = None
mock_response.tool_calls = [mock_tool_call]
mock_response.prompt_tokens = 50
mock_response.completion_tokens = 25
mock_response.reasoning = None
mock_response.raw_response = {"role": "assistant", "content": None}
mock_tool_signatures = [
{
"type": "function",
"function": {
"name": "search_keywords",
"_sink_node_id": "test-sink-node-id",
"_field_mapping": {},
"parameters": {
"properties": {
"query": {"type": "string"},
"max_keyword_difficulty": {"type": "integer"},
},
"required": ["query", "max_keyword_difficulty"],
},
},
}
]
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
return_value=mock_response,
), patch.object(
block, "_create_tool_node_signatures", return_value=mock_tool_signatures
):
# Test default behavior (traditional mode)
input_data = SmartDecisionMakerBlock.Input(
prompt="Test prompt",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0, # Traditional mode
)
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
outputs = {}
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
graph_id="test-graph-id",
node_id="test-node-id",
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
# Verify traditional mode behavior
assert (
"tool_functions" in outputs
) # Should yield tool_functions in traditional mode
assert (
"tools_^_test-sink-node-id_~_query" in outputs
) # Should yield individual tool parameters
assert "tools_^_test-sink-node-id_~_max_keyword_difficulty" in outputs
assert "conversations" in outputs

View File

@@ -1,7 +1,7 @@
"""Comprehensive tests for SmartDecisionMakerBlock dynamic field handling."""
import json
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -308,10 +308,47 @@ async def test_output_yielding_with_dynamic_fields():
) as mock_llm:
mock_llm.return_value = mock_response
# Mock the function signature creation
with patch.object(
# Mock the database manager to avoid HTTP calls during tool execution
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client"
) as mock_db_manager, patch.object(
block, "_create_tool_node_signatures", new_callable=AsyncMock
) as mock_sig:
# Set up the mock database manager
mock_db_client = AsyncMock()
mock_db_manager.return_value = mock_db_client
# Mock the node retrieval
mock_target_node = Mock()
mock_target_node.id = "test-sink-node-id"
mock_target_node.block_id = "CreateDictionaryBlock"
mock_target_node.block = Mock()
mock_target_node.block.name = "Create Dictionary"
mock_db_client.get_node.return_value = mock_target_node
# Mock the execution result creation
mock_node_exec_result = Mock()
mock_node_exec_result.node_exec_id = "mock-node-exec-id"
mock_final_input_data = {
"values_#_name": "Alice",
"values_#_age": 30,
"values_#_email": "alice@example.com",
}
mock_db_client.upsert_execution_input.return_value = (
mock_node_exec_result,
mock_final_input_data,
)
# Mock the output retrieval
mock_outputs = {
"values_#_name": "Alice",
"values_#_age": 30,
"values_#_email": "alice@example.com",
}
mock_db_client.get_execution_outputs_by_node_exec_id.return_value = (
mock_outputs
)
mock_sig.return_value = [
{
"type": "function",
@@ -337,10 +374,16 @@ async def test_output_yielding_with_dynamic_fields():
prompt="Create a user dictionary",
credentials=llm.TEST_CREDENTIALS_INPUT,
model=llm.LlmModel.GPT4O,
agent_mode_max_iterations=0, # Use traditional mode to test output yielding
)
# Run the block
outputs = {}
from backend.data.execution import ExecutionContext
mock_execution_context = ExecutionContext(safe_mode=False)
mock_execution_processor = MagicMock()
async for output_name, output_value in block.run(
input_data,
credentials=llm.TEST_CREDENTIALS,
@@ -349,6 +392,9 @@ async def test_output_yielding_with_dynamic_fields():
graph_exec_id="test_exec",
node_exec_id="test_node_exec",
user_id="test_user",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_value
@@ -511,45 +557,108 @@ async def test_validation_errors_dont_pollute_conversation():
}
]
# Create input data
from backend.blocks import llm
# Mock the database manager to avoid HTTP calls during tool execution
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client"
) as mock_db_manager:
# Set up the mock database manager for agent mode
mock_db_client = AsyncMock()
mock_db_manager.return_value = mock_db_client
input_data = block.input_schema(
prompt="Test prompt",
credentials=llm.TEST_CREDENTIALS_INPUT,
model=llm.LlmModel.GPT4O,
retry=3, # Allow retries
)
# Mock the node retrieval
mock_target_node = Mock()
mock_target_node.id = "test-sink-node-id"
mock_target_node.block_id = "TestBlock"
mock_target_node.block = Mock()
mock_target_node.block.name = "Test Block"
mock_db_client.get_node.return_value = mock_target_node
# Run the block
outputs = {}
async for output_name, output_value in block.run(
input_data,
credentials=llm.TEST_CREDENTIALS,
graph_id="test_graph",
node_id="test_node",
graph_exec_id="test_exec",
node_exec_id="test_node_exec",
user_id="test_user",
):
outputs[output_name] = output_value
# Mock the execution result creation
mock_node_exec_result = Mock()
mock_node_exec_result.node_exec_id = "mock-node-exec-id"
mock_final_input_data = {"correct_param": "value"}
mock_db_client.upsert_execution_input.return_value = (
mock_node_exec_result,
mock_final_input_data,
)
# Verify we had 2 LLM calls (initial + retry)
assert call_count == 2
# Mock the output retrieval
mock_outputs = {"correct_param": "value"}
mock_db_client.get_execution_outputs_by_node_exec_id.return_value = (
mock_outputs
)
# Check the final conversation output
final_conversation = outputs.get("conversations", [])
# Create input data
from backend.blocks import llm
# The final conversation should NOT contain the validation error message
error_messages = [
msg
for msg in final_conversation
if msg.get("role") == "user"
and "parameter errors" in msg.get("content", "")
]
assert (
len(error_messages) == 0
), "Validation error leaked into final conversation"
input_data = block.input_schema(
prompt="Test prompt",
credentials=llm.TEST_CREDENTIALS_INPUT,
model=llm.LlmModel.GPT4O,
retry=3, # Allow retries
agent_mode_max_iterations=1,
)
# The final conversation should only have the successful response
assert final_conversation[-1]["content"] == "valid"
# Run the block
outputs = {}
from backend.data.execution import ExecutionContext
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a proper mock execution processor for agent mode
from collections import defaultdict
mock_execution_processor = AsyncMock()
mock_execution_processor.execution_stats = MagicMock()
mock_execution_processor.execution_stats_lock = MagicMock()
# Create a mock NodeExecutionProgress for the sink node
mock_node_exec_progress = MagicMock()
mock_node_exec_progress.add_task = MagicMock()
mock_node_exec_progress.pop_output = MagicMock(
return_value=None
) # No outputs to process
# Set up running_node_execution as a defaultdict that returns our mock for any key
mock_execution_processor.running_node_execution = defaultdict(
lambda: mock_node_exec_progress
)
# Mock the on_node_execution method that gets called during tool execution
mock_node_stats = MagicMock()
mock_node_stats.error = None
mock_execution_processor.on_node_execution.return_value = (
mock_node_stats
)
async for output_name, output_value in block.run(
input_data,
credentials=llm.TEST_CREDENTIALS,
graph_id="test_graph",
node_id="test_node",
graph_exec_id="test_exec",
node_exec_id="test_node_exec",
user_id="test_user",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_value
# Verify we had at least 1 LLM call
assert call_count >= 1
# Check the final conversation output
final_conversation = outputs.get("conversations", [])
# The final conversation should NOT contain validation error messages
# Even if retries don't happen in agent mode, we should not leak errors
error_messages = [
msg
for msg in final_conversation
if msg.get("role") == "user"
and "parameter errors" in msg.get("content", "")
]
assert (
len(error_messages) == 0
), "Validation error leaked into final conversation"

View File

@@ -1,12 +1,45 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import prisma.types
from pydantic import BaseModel
from backend.data.db import query_raw_with_schema
from backend.util.json import SafeJson
logger = logging.getLogger(__name__)
class AccuracyAlertData(BaseModel):
"""Alert data when accuracy drops significantly."""
graph_id: str
user_id: Optional[str]
drop_percent: float
three_day_avg: float
seven_day_avg: float
detected_at: datetime
class AccuracyLatestData(BaseModel):
"""Latest execution accuracy data point."""
date: datetime
daily_score: Optional[float]
three_day_avg: Optional[float]
seven_day_avg: Optional[float]
fourteen_day_avg: Optional[float]
class AccuracyTrendsResponse(BaseModel):
"""Response model for accuracy trends and alerts."""
latest_data: AccuracyLatestData
alert: Optional[AccuracyAlertData]
historical_data: Optional[list[AccuracyLatestData]] = None
async def log_raw_analytics(
user_id: str,
type: str,
@@ -43,3 +76,217 @@ async def log_raw_metric(
)
return result
async def get_accuracy_trends_and_alerts(
graph_id: str,
days_back: int = 30,
user_id: Optional[str] = None,
drop_threshold: float = 10.0,
include_historical: bool = False,
) -> AccuracyTrendsResponse:
"""Get accuracy trends and detect alerts for a specific graph."""
query_template = """
WITH daily_scores AS (
SELECT
DATE(e."createdAt") as execution_date,
AVG(CASE
WHEN e.stats IS NOT NULL
AND e.stats::json->>'correctness_score' IS NOT NULL
AND e.stats::json->>'correctness_score' != 'null'
THEN (e.stats::json->>'correctness_score')::float * 100
ELSE NULL
END) as daily_score
FROM {schema_prefix}"AgentGraphExecution" e
WHERE e."agentGraphId" = $1::text
AND e."isDeleted" = false
AND e."createdAt" >= $2::timestamp
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
{user_filter}
GROUP BY DATE(e."createdAt")
HAVING COUNT(*) >= 3 -- Need at least 3 executions per day
),
trends AS (
SELECT
execution_date,
daily_score,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as three_day_avg,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as seven_day_avg,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW
) as fourteen_day_avg
FROM daily_scores
)
SELECT *,
CASE
WHEN three_day_avg IS NOT NULL AND seven_day_avg IS NOT NULL AND seven_day_avg > 0
THEN ((seven_day_avg - three_day_avg) / seven_day_avg * 100)
ELSE NULL
END as drop_percent
FROM trends
ORDER BY execution_date DESC
{limit_clause}
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
params = [graph_id, start_date]
user_filter = ""
if user_id:
user_filter = 'AND e."userId" = $3::text'
params.append(user_id)
# Determine limit clause
limit_clause = "" if include_historical else "LIMIT 1"
final_query = query_template.format(
schema_prefix="{schema_prefix}",
user_filter=user_filter,
limit_clause=limit_clause,
)
result = await query_raw_with_schema(final_query, *params)
if not result:
return AccuracyTrendsResponse(
latest_data=AccuracyLatestData(
date=datetime.now(timezone.utc),
daily_score=None,
three_day_avg=None,
seven_day_avg=None,
fourteen_day_avg=None,
),
alert=None,
)
latest = result[0]
alert = None
if (
latest["drop_percent"] is not None
and latest["drop_percent"] >= drop_threshold
and latest["three_day_avg"] is not None
and latest["seven_day_avg"] is not None
):
alert = AccuracyAlertData(
graph_id=graph_id,
user_id=user_id,
drop_percent=float(latest["drop_percent"]),
three_day_avg=float(latest["three_day_avg"]),
seven_day_avg=float(latest["seven_day_avg"]),
detected_at=datetime.now(timezone.utc),
)
# Prepare historical data if requested
historical_data = None
if include_historical:
historical_data = []
for row in result:
historical_data.append(
AccuracyLatestData(
date=row["execution_date"],
daily_score=(
float(row["daily_score"])
if row["daily_score"] is not None
else None
),
three_day_avg=(
float(row["three_day_avg"])
if row["three_day_avg"] is not None
else None
),
seven_day_avg=(
float(row["seven_day_avg"])
if row["seven_day_avg"] is not None
else None
),
fourteen_day_avg=(
float(row["fourteen_day_avg"])
if row["fourteen_day_avg"] is not None
else None
),
)
)
return AccuracyTrendsResponse(
latest_data=AccuracyLatestData(
date=latest["execution_date"],
daily_score=(
float(latest["daily_score"])
if latest["daily_score"] is not None
else None
),
three_day_avg=(
float(latest["three_day_avg"])
if latest["three_day_avg"] is not None
else None
),
seven_day_avg=(
float(latest["seven_day_avg"])
if latest["seven_day_avg"] is not None
else None
),
fourteen_day_avg=(
float(latest["fourteen_day_avg"])
if latest["fourteen_day_avg"] is not None
else None
),
),
alert=alert,
historical_data=historical_data,
)
class MarketplaceGraphData(BaseModel):
"""Data structure for marketplace graph monitoring."""
graph_id: str
user_id: Optional[str]
execution_count: int
async def get_marketplace_graphs_for_monitoring(
days_back: int = 30,
min_executions: int = 10,
) -> list[MarketplaceGraphData]:
"""Get published marketplace graphs with recent executions for monitoring."""
query_template = """
WITH marketplace_graphs AS (
SELECT DISTINCT
slv."agentGraphId" as graph_id,
slv."agentGraphVersion" as graph_version
FROM {schema_prefix}"StoreListing" sl
JOIN {schema_prefix}"StoreListingVersion" slv ON sl."activeVersionId" = slv."id"
WHERE sl."hasApprovedVersion" = true
AND sl."isDeleted" = false
)
SELECT DISTINCT
mg.graph_id,
NULL as user_id, -- Marketplace graphs don't have a specific user_id for monitoring
COUNT(*) as execution_count
FROM marketplace_graphs mg
JOIN {schema_prefix}"AgentGraphExecution" e ON e."agentGraphId" = mg.graph_id
WHERE e."createdAt" >= $1::timestamp
AND e."isDeleted" = false
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
GROUP BY mg.graph_id
HAVING COUNT(*) >= $2
ORDER BY execution_count DESC
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
result = await query_raw_with_schema(query_template, start_date, min_executions)
return [
MarketplaceGraphData(
graph_id=row["graph_id"],
user_id=row["user_id"],
execution_count=int(row["execution_count"]),
)
for row in result
]

View File

@@ -1,10 +1,10 @@
import logging
import queue
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from enum import Enum
from multiprocessing import Manager
from queue import Empty
from typing import (
TYPE_CHECKING,
Annotated,
Any,
AsyncGenerator,
@@ -65,6 +65,9 @@ from .includes import (
)
from .model import CredentialsMetaInput, GraphExecutionStats, NodeExecutionStats
if TYPE_CHECKING:
pass
T = TypeVar("T")
logger = logging.getLogger(__name__)
@@ -836,6 +839,30 @@ async def upsert_execution_output(
await AgentNodeExecutionInputOutput.prisma().create(data=data)
async def get_execution_outputs_by_node_exec_id(
node_exec_id: str,
) -> dict[str, Any]:
"""
Get all execution outputs for a specific node execution ID.
Args:
node_exec_id: The node execution ID to get outputs for
Returns:
Dictionary mapping output names to their data values
"""
outputs = await AgentNodeExecutionInputOutput.prisma().find_many(
where={"referencedByOutputExecId": node_exec_id}
)
result = {}
for output in outputs:
if output.data is not None:
result[output.name] = type_utils.convert(output.data, JsonValue)
return result
async def update_graph_execution_start_time(
graph_exec_id: str,
) -> GraphExecution | None:
@@ -1136,12 +1163,16 @@ class NodeExecutionEntry(BaseModel):
class ExecutionQueue(Generic[T]):
"""
Queue for managing the execution of agents.
This will be shared between different processes
Thread-safe queue for managing node execution within a single graph execution.
Note: Uses queue.Queue (not multiprocessing.Queue) since all access is from
threads within the same process. If migrating back to ProcessPoolExecutor,
replace with multiprocessing.Manager().Queue() for cross-process safety.
"""
def __init__(self):
self.queue = Manager().Queue()
# Thread-safe queue (not multiprocessing) — see class docstring
self.queue: queue.Queue[T] = queue.Queue()
def add(self, execution: T) -> T:
self.queue.put(execution)
@@ -1156,7 +1187,7 @@ class ExecutionQueue(Generic[T]):
def get_or_none(self) -> T | None:
try:
return self.queue.get_nowait()
except Empty:
except queue.Empty:
return None
@@ -1465,3 +1496,35 @@ async def get_graph_execution_by_share_token(
created_at=execution.createdAt,
outputs=outputs,
)
async def get_frequently_executed_graphs(
days_back: int = 30,
min_executions: int = 10,
) -> list[dict]:
"""Get graphs that have been frequently executed for monitoring."""
query_template = """
SELECT DISTINCT
e."agentGraphId" as graph_id,
e."userId" as user_id,
COUNT(*) as execution_count
FROM {schema_prefix}"AgentGraphExecution" e
WHERE e."createdAt" >= $1::timestamp
AND e."isDeleted" = false
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
GROUP BY e."agentGraphId", e."userId"
HAVING COUNT(*) >= $2
ORDER BY execution_count DESC
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
result = await query_raw_with_schema(query_template, start_date, min_executions)
return [
{
"graph_id": row["graph_id"],
"user_id": row["user_id"],
"execution_count": int(row["execution_count"]),
}
for row in result
]

View File

@@ -0,0 +1,60 @@
"""Tests for ExecutionQueue thread-safety."""
import queue
import threading
import pytest
from backend.data.execution import ExecutionQueue
def test_execution_queue_uses_stdlib_queue():
"""Verify ExecutionQueue uses queue.Queue (not multiprocessing)."""
q = ExecutionQueue()
assert isinstance(q.queue, queue.Queue)
def test_basic_operations():
"""Test add, get, empty, and get_or_none."""
q = ExecutionQueue()
assert q.empty() is True
assert q.get_or_none() is None
result = q.add("item1")
assert result == "item1"
assert q.empty() is False
item = q.get()
assert item == "item1"
assert q.empty() is True
def test_thread_safety():
"""Test concurrent access from multiple threads."""
q = ExecutionQueue()
results = []
num_items = 100
def producer():
for i in range(num_items):
q.add(f"item_{i}")
def consumer():
count = 0
while count < num_items:
item = q.get_or_none()
if item is not None:
results.append(item)
count += 1
producer_thread = threading.Thread(target=producer)
consumer_thread = threading.Thread(target=consumer)
producer_thread.start()
consumer_thread.start()
producer_thread.join(timeout=5)
consumer_thread.join(timeout=5)
assert len(results) == num_items

View File

@@ -3,12 +3,18 @@ from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Callable, Concatenate, ParamSpec, TypeVar, cast
from backend.data import db
from backend.data.analytics import (
get_accuracy_trends_and_alerts,
get_marketplace_graphs_for_monitoring,
)
from backend.data.credit import UsageTransactionMetadata, get_user_credit_model
from backend.data.execution import (
create_graph_execution,
get_block_error_stats,
get_child_graph_executions,
get_execution_kv_data,
get_execution_outputs_by_node_exec_id,
get_frequently_executed_graphs,
get_graph_execution_meta,
get_graph_executions,
get_graph_executions_count,
@@ -142,9 +148,13 @@ class DatabaseManager(AppService):
update_graph_execution_stats = _(update_graph_execution_stats)
upsert_execution_input = _(upsert_execution_input)
upsert_execution_output = _(upsert_execution_output)
get_execution_outputs_by_node_exec_id = _(get_execution_outputs_by_node_exec_id)
get_execution_kv_data = _(get_execution_kv_data)
set_execution_kv_data = _(set_execution_kv_data)
get_block_error_stats = _(get_block_error_stats)
get_accuracy_trends_and_alerts = _(get_accuracy_trends_and_alerts)
get_frequently_executed_graphs = _(get_frequently_executed_graphs)
get_marketplace_graphs_for_monitoring = _(get_marketplace_graphs_for_monitoring)
# Graphs
get_node = _(get_node)
@@ -226,6 +236,10 @@ class DatabaseManagerClient(AppServiceClient):
# Block error monitoring
get_block_error_stats = _(d.get_block_error_stats)
# Execution accuracy monitoring
get_accuracy_trends_and_alerts = _(d.get_accuracy_trends_and_alerts)
get_frequently_executed_graphs = _(d.get_frequently_executed_graphs)
get_marketplace_graphs_for_monitoring = _(d.get_marketplace_graphs_for_monitoring)
# Human In The Loop
has_pending_reviews_for_graph_exec = _(d.has_pending_reviews_for_graph_exec)
@@ -265,6 +279,7 @@ class DatabaseManagerAsyncClient(AppServiceClient):
get_user_integrations = d.get_user_integrations
upsert_execution_input = d.upsert_execution_input
upsert_execution_output = d.upsert_execution_output
get_execution_outputs_by_node_exec_id = d.get_execution_outputs_by_node_exec_id
update_graph_execution_stats = d.update_graph_execution_stats
update_node_execution_status = d.update_node_execution_status
update_node_execution_status_batch = d.update_node_execution_status_batch

View File

@@ -133,9 +133,8 @@ def execute_graph(
cluster_lock: ClusterLock,
):
"""Execute graph using thread-local ExecutionProcessor instance"""
return _tls.processor.on_graph_execution(
graph_exec_entry, cancel_event, cluster_lock
)
processor: ExecutionProcessor = _tls.processor
return processor.on_graph_execution(graph_exec_entry, cancel_event, cluster_lock)
T = TypeVar("T")
@@ -143,8 +142,8 @@ T = TypeVar("T")
async def execute_node(
node: Node,
creds_manager: IntegrationCredentialsManager,
data: NodeExecutionEntry,
execution_processor: "ExecutionProcessor",
execution_stats: NodeExecutionStats | None = None,
nodes_input_masks: Optional[NodesInputMasks] = None,
) -> BlockOutput:
@@ -169,6 +168,7 @@ async def execute_node(
node_id = data.node_id
node_block = node.block
execution_context = data.execution_context
creds_manager = execution_processor.creds_manager
log_metadata = LogMetadata(
logger=_logger,
@@ -212,6 +212,7 @@ async def execute_node(
"node_exec_id": node_exec_id,
"user_id": user_id,
"execution_context": execution_context,
"execution_processor": execution_processor,
}
# Last-minute fetch credentials + acquire a system-wide read-write lock to prevent
@@ -608,8 +609,8 @@ class ExecutionProcessor:
async for output_name, output_data in execute_node(
node=node,
creds_manager=self.creds_manager,
data=node_exec,
execution_processor=self,
execution_stats=stats,
nodes_input_masks=nodes_input_masks,
):
@@ -860,12 +861,17 @@ class ExecutionProcessor:
execution_stats_lock = threading.Lock()
# State holders ----------------------------------------------------
running_node_execution: dict[str, NodeExecutionProgress] = defaultdict(
self.running_node_execution: dict[str, NodeExecutionProgress] = defaultdict(
NodeExecutionProgress
)
running_node_evaluation: dict[str, Future] = {}
self.running_node_evaluation: dict[str, Future] = {}
self.execution_stats = execution_stats
self.execution_stats_lock = execution_stats_lock
execution_queue = ExecutionQueue[NodeExecutionEntry]()
running_node_execution = self.running_node_execution
running_node_evaluation = self.running_node_evaluation
try:
if db_client.get_credits(graph_exec.user_id) <= 0:
raise InsufficientBalanceError(

View File

@@ -33,6 +33,7 @@ from backend.monitoring import (
process_existing_batches,
process_weekly_summary,
report_block_error_rates,
report_execution_accuracy_alerts,
report_late_executions,
)
from backend.util.clients import get_scheduler_client
@@ -241,6 +242,11 @@ def cleanup_expired_files():
run_async(cleanup_expired_files_async())
def execution_accuracy_alerts():
"""Check execution accuracy and send alerts if drops are detected."""
return report_execution_accuracy_alerts()
# Monitoring functions are now imported from monitoring module
@@ -440,6 +446,17 @@ class Scheduler(AppService):
jobstore=Jobstores.EXECUTION.value,
)
# Execution Accuracy Monitoring - configurable interval
self.scheduler.add_job(
execution_accuracy_alerts,
id="report_execution_accuracy_alerts",
trigger="interval",
replace_existing=True,
seconds=config.execution_accuracy_check_interval_hours
* 3600, # Convert hours to seconds
jobstore=Jobstores.EXECUTION.value,
)
self.scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
self.scheduler.add_listener(job_missed_listener, EVENT_JOB_MISSED)
self.scheduler.add_listener(job_max_instances_listener, EVENT_JOB_MAX_INSTANCES)
@@ -587,6 +604,11 @@ class Scheduler(AppService):
"""Manually trigger cleanup of expired cloud storage files."""
return cleanup_expired_files()
@expose
def execute_report_execution_accuracy_alerts(self):
"""Manually trigger execution accuracy alert checking."""
return execution_accuracy_alerts()
class SchedulerClient(AppServiceClient):
@classmethod

View File

@@ -1,5 +1,6 @@
"""Monitoring module for platform health and alerting."""
from .accuracy_monitor import AccuracyMonitor, report_execution_accuracy_alerts
from .block_error_monitor import BlockErrorMonitor, report_block_error_rates
from .late_execution_monitor import (
LateExecutionException,
@@ -13,10 +14,12 @@ from .notification_monitor import (
)
__all__ = [
"AccuracyMonitor",
"BlockErrorMonitor",
"LateExecutionMonitor",
"LateExecutionException",
"NotificationJobArgs",
"report_execution_accuracy_alerts",
"report_block_error_rates",
"report_late_executions",
"process_existing_batches",

View File

@@ -0,0 +1,107 @@
"""Execution accuracy monitoring module."""
import logging
from backend.util.clients import (
get_database_manager_client,
get_notification_manager_client,
)
from backend.util.metrics import DiscordChannel, sentry_capture_error
from backend.util.settings import Config
logger = logging.getLogger(__name__)
config = Config()
class AccuracyMonitor:
"""Monitor execution accuracy trends and send alerts for drops."""
def __init__(self, drop_threshold: float = 10.0):
self.config = config
self.notification_client = get_notification_manager_client()
self.database_client = get_database_manager_client()
self.drop_threshold = drop_threshold
def check_execution_accuracy_alerts(self) -> str:
"""Check marketplace agents for accuracy drops and send alerts."""
try:
logger.info("Checking execution accuracy for marketplace agents")
# Get marketplace graphs using database client
graphs = self.database_client.get_marketplace_graphs_for_monitoring(
days_back=30, min_executions=10
)
alerts_found = 0
for graph_data in graphs:
result = self.database_client.get_accuracy_trends_and_alerts(
graph_id=graph_data.graph_id,
user_id=graph_data.user_id,
days_back=21, # 3 weeks
drop_threshold=self.drop_threshold,
)
if result.alert:
alert = result.alert
# Get graph details for better alert info
try:
graph_info = self.database_client.get_graph_metadata(
graph_id=alert.graph_id
)
graph_name = graph_info.name if graph_info else "Unknown Agent"
except Exception:
graph_name = "Unknown Agent"
# Create detailed alert message
alert_msg = (
f"🚨 **AGENT ACCURACY DROP DETECTED**\n\n"
f"**Agent:** {graph_name}\n"
f"**Graph ID:** `{alert.graph_id}`\n"
f"**Accuracy Drop:** {alert.drop_percent:.1f}%\n"
f"**Recent Performance:**\n"
f" • 3-day average: {alert.three_day_avg:.1f}%\n"
f" • 7-day average: {alert.seven_day_avg:.1f}%\n"
)
if alert.user_id:
alert_msg += f"**Owner:** {alert.user_id}\n"
# Send individual alert for each agent (not batched)
self.notification_client.discord_system_alert(
alert_msg, DiscordChannel.PRODUCT
)
alerts_found += 1
logger.warning(
f"Sent accuracy alert for agent: {graph_name} ({alert.graph_id})"
)
if alerts_found > 0:
return f"Alert sent for {alerts_found} agents with accuracy drops"
logger.info("No execution accuracy alerts detected")
return "No accuracy alerts detected"
except Exception as e:
logger.exception(f"Error checking execution accuracy alerts: {e}")
error = Exception(f"Error checking execution accuracy alerts: {e}")
msg = str(error)
sentry_capture_error(error)
self.notification_client.discord_system_alert(msg, DiscordChannel.PRODUCT)
return msg
def report_execution_accuracy_alerts(drop_threshold: float = 10.0) -> str:
"""
Check execution accuracy and send alerts if drops are detected.
Args:
drop_threshold: Percentage drop threshold to trigger alerts (default 10.0%)
Returns:
Status message indicating results of the check
"""
monitor = AccuracyMonitor(drop_threshold=drop_threshold)
return monitor.check_execution_accuracy_alerts()

View File

@@ -8,6 +8,10 @@ from fastapi import APIRouter, HTTPException, Security
from pydantic import BaseModel, Field
from backend.blocks.llm import LlmModel
from backend.data.analytics import (
AccuracyTrendsResponse,
get_accuracy_trends_and_alerts,
)
from backend.data.execution import (
ExecutionStatus,
GraphExecutionMeta,
@@ -83,6 +87,18 @@ class ExecutionAnalyticsConfig(BaseModel):
recommended_model: str
class AccuracyTrendsRequest(BaseModel):
graph_id: str = Field(..., description="Graph ID to analyze", min_length=1)
user_id: Optional[str] = Field(None, description="Optional user ID filter")
days_back: int = Field(30, description="Number of days to look back", ge=7, le=90)
drop_threshold: float = Field(
10.0, description="Alert threshold percentage", ge=1.0, le=50.0
)
include_historical: bool = Field(
False, description="Include historical data for charts"
)
router = APIRouter(
prefix="/admin",
tags=["admin", "execution_analytics"],
@@ -419,3 +435,40 @@ async def _process_batch(
return await asyncio.gather(
*[process_single_execution(execution) for execution in executions]
)
@router.get(
"/execution_accuracy_trends",
response_model=AccuracyTrendsResponse,
summary="Get Execution Accuracy Trends and Alerts",
)
async def get_execution_accuracy_trends(
graph_id: str,
user_id: Optional[str] = None,
days_back: int = 30,
drop_threshold: float = 10.0,
include_historical: bool = False,
admin_user_id: str = Security(get_user_id),
) -> AccuracyTrendsResponse:
"""
Get execution accuracy trends with moving averages and alert detection.
Simple single-query approach.
"""
logger.info(
f"Admin user {admin_user_id} requesting accuracy trends for graph {graph_id}"
)
try:
result = await get_accuracy_trends_and_alerts(
graph_id=graph_id,
days_back=days_back,
user_id=user_id,
drop_threshold=drop_threshold,
include_historical=include_historical,
)
return result
except Exception as e:
logger.exception(f"Error getting accuracy trends for graph {graph_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,9 +1,16 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from difflib import SequenceMatcher
from typing import Sequence
import prisma
import backend.data.block
import backend.server.v2.library.db as library_db
import backend.server.v2.library.model as library_model
import backend.server.v2.store.db as store_db
import backend.server.v2.store.model as store_model
from backend.blocks import load_all_blocks
from backend.blocks.llm import LlmModel
from backend.data.block import AnyBlockSchema, BlockCategory, BlockInfo, BlockSchema
@@ -14,17 +21,36 @@ from backend.server.v2.builder.model import (
BlockResponse,
BlockType,
CountResponse,
FilterType,
Provider,
ProviderResponse,
SearchBlocksResponse,
SearchEntry,
)
from backend.util.cache import cached
from backend.util.models import Pagination
logger = logging.getLogger(__name__)
llm_models = [name.name.lower().replace("_", " ") for name in LlmModel]
_static_counts_cache: dict | None = None
_suggested_blocks: list[BlockInfo] | None = None
MAX_LIBRARY_AGENT_RESULTS = 100
MAX_MARKETPLACE_AGENT_RESULTS = 100
MIN_SCORE_FOR_FILTERED_RESULTS = 10.0
SearchResultItem = BlockInfo | library_model.LibraryAgent | store_model.StoreAgent
@dataclass
class _ScoredItem:
item: SearchResultItem
filter_type: FilterType
score: float
sort_key: str
@dataclass
class _SearchCacheEntry:
items: list[SearchResultItem]
total_items: dict[FilterType, int]
def get_block_categories(category_blocks: int = 3) -> list[BlockCategoryResponse]:
@@ -130,71 +156,244 @@ def get_block_by_id(block_id: str) -> BlockInfo | None:
return None
def search_blocks(
include_blocks: bool = True,
include_integrations: bool = True,
query: str = "",
page: int = 1,
page_size: int = 50,
) -> SearchBlocksResponse:
async def update_search(user_id: str, search: SearchEntry) -> str:
"""
Get blocks based on the filter and query.
`providers` only applies for `integrations` filter.
Upsert a search request for the user and return the search ID.
"""
blocks: list[AnyBlockSchema] = []
query = query.lower()
if search.search_id:
# Update existing search
await prisma.models.BuilderSearchHistory.prisma().update(
where={
"id": search.search_id,
},
data={
"searchQuery": search.search_query or "",
"filter": search.filter or [], # type: ignore
"byCreator": search.by_creator or [],
},
)
return search.search_id
else:
# Create new search
new_search = await prisma.models.BuilderSearchHistory.prisma().create(
data={
"userId": user_id,
"searchQuery": search.search_query or "",
"filter": search.filter or [], # type: ignore
"byCreator": search.by_creator or [],
}
)
return new_search.id
total = 0
skip = (page - 1) * page_size
take = page_size
async def get_recent_searches(user_id: str, limit: int = 5) -> list[SearchEntry]:
"""
Get the user's most recent search requests.
"""
searches = await prisma.models.BuilderSearchHistory.prisma().find_many(
where={
"userId": user_id,
},
order={
"updatedAt": "desc",
},
take=limit,
)
return [
SearchEntry(
search_query=s.searchQuery,
filter=s.filter, # type: ignore
by_creator=s.byCreator,
search_id=s.id,
)
for s in searches
]
async def get_sorted_search_results(
*,
user_id: str,
search_query: str | None,
filters: Sequence[FilterType],
by_creator: Sequence[str] | None = None,
) -> _SearchCacheEntry:
normalized_filters: tuple[FilterType, ...] = tuple(sorted(set(filters or [])))
normalized_creators: tuple[str, ...] = tuple(sorted(set(by_creator or [])))
return await _build_cached_search_results(
user_id=user_id,
search_query=search_query or "",
filters=normalized_filters,
by_creator=normalized_creators,
)
@cached(ttl_seconds=300, shared_cache=True)
async def _build_cached_search_results(
user_id: str,
search_query: str,
filters: tuple[FilterType, ...],
by_creator: tuple[str, ...],
) -> _SearchCacheEntry:
normalized_query = (search_query or "").strip().lower()
include_blocks = "blocks" in filters
include_integrations = "integrations" in filters
include_library_agents = "my_agents" in filters
include_marketplace_agents = "marketplace_agents" in filters
scored_items: list[_ScoredItem] = []
total_items: dict[FilterType, int] = {
"blocks": 0,
"integrations": 0,
"marketplace_agents": 0,
"my_agents": 0,
}
block_results, block_total, integration_total = _collect_block_results(
normalized_query=normalized_query,
include_blocks=include_blocks,
include_integrations=include_integrations,
)
scored_items.extend(block_results)
total_items["blocks"] = block_total
total_items["integrations"] = integration_total
if include_library_agents:
library_response = await library_db.list_library_agents(
user_id=user_id,
search_term=search_query or None,
page=1,
page_size=MAX_LIBRARY_AGENT_RESULTS,
)
total_items["my_agents"] = library_response.pagination.total_items
scored_items.extend(
_build_library_items(
agents=library_response.agents,
normalized_query=normalized_query,
)
)
if include_marketplace_agents:
marketplace_response = await store_db.get_store_agents(
creators=list(by_creator) or None,
search_query=search_query or None,
page=1,
page_size=MAX_MARKETPLACE_AGENT_RESULTS,
)
total_items["marketplace_agents"] = marketplace_response.pagination.total_items
scored_items.extend(
_build_marketplace_items(
agents=marketplace_response.agents,
normalized_query=normalized_query,
)
)
sorted_items = sorted(
scored_items,
key=lambda entry: (-entry.score, entry.sort_key, entry.filter_type),
)
return _SearchCacheEntry(
items=[entry.item for entry in sorted_items],
total_items=total_items,
)
def _collect_block_results(
*,
normalized_query: str,
include_blocks: bool,
include_integrations: bool,
) -> tuple[list[_ScoredItem], int, int]:
results: list[_ScoredItem] = []
block_count = 0
integration_count = 0
if not include_blocks and not include_integrations:
return results, block_count, integration_count
for block_type in load_all_blocks().values():
block: AnyBlockSchema = block_type()
# Skip disabled blocks
if block.disabled:
continue
# Skip blocks that don't match the query
if (
query not in block.name.lower()
and query not in block.description.lower()
and not _matches_llm_model(block.input_schema, query)
):
continue
keep = False
block_info = block.get_info()
credentials = list(block.input_schema.get_credentials_fields().values())
if include_integrations and len(credentials) > 0:
keep = True
is_integration = len(credentials) > 0
if is_integration and not include_integrations:
continue
if not is_integration and not include_blocks:
continue
score = _score_block(block, block_info, normalized_query)
if not _should_include_item(score, normalized_query):
continue
filter_type: FilterType = "integrations" if is_integration else "blocks"
if is_integration:
integration_count += 1
if include_blocks and len(credentials) == 0:
keep = True
else:
block_count += 1
if not keep:
results.append(
_ScoredItem(
item=block_info,
filter_type=filter_type,
score=score,
sort_key=_get_item_name(block_info),
)
)
return results, block_count, integration_count
def _build_library_items(
*,
agents: list[library_model.LibraryAgent],
normalized_query: str,
) -> list[_ScoredItem]:
results: list[_ScoredItem] = []
for agent in agents:
score = _score_library_agent(agent, normalized_query)
if not _should_include_item(score, normalized_query):
continue
total += 1
if skip > 0:
skip -= 1
continue
if take > 0:
take -= 1
blocks.append(block)
results.append(
_ScoredItem(
item=agent,
filter_type="my_agents",
score=score,
sort_key=_get_item_name(agent),
)
)
return SearchBlocksResponse(
blocks=BlockResponse(
blocks=[b.get_info() for b in blocks],
pagination=Pagination(
total_items=total,
total_pages=(total + page_size - 1) // page_size,
current_page=page,
page_size=page_size,
),
),
total_block_count=block_count,
total_integration_count=integration_count,
)
return results
def _build_marketplace_items(
*,
agents: list[store_model.StoreAgent],
normalized_query: str,
) -> list[_ScoredItem]:
results: list[_ScoredItem] = []
for agent in agents:
score = _score_store_agent(agent, normalized_query)
if not _should_include_item(score, normalized_query):
continue
results.append(
_ScoredItem(
item=agent,
filter_type="marketplace_agents",
score=score,
sort_key=_get_item_name(agent),
)
)
return results
def get_providers(
@@ -251,16 +450,12 @@ async def get_counts(user_id: str) -> CountResponse:
)
@cached(ttl_seconds=3600)
async def _get_static_counts():
"""
Get counts of blocks, integrations, and marketplace agents.
This is cached to avoid unnecessary database queries and calculations.
Can't use functools.cache here because the function is async.
"""
global _static_counts_cache
if _static_counts_cache is not None:
return _static_counts_cache
all_blocks = 0
input_blocks = 0
action_blocks = 0
@@ -287,7 +482,7 @@ async def _get_static_counts():
marketplace_agents = await prisma.models.StoreAgent.prisma().count()
_static_counts_cache = {
return {
"all_blocks": all_blocks,
"input_blocks": input_blocks,
"action_blocks": action_blocks,
@@ -296,8 +491,6 @@ async def _get_static_counts():
"marketplace_agents": marketplace_agents,
}
return _static_counts_cache
def _matches_llm_model(schema_cls: type[BlockSchema], query: str) -> bool:
for field in schema_cls.model_fields.values():
@@ -308,6 +501,123 @@ def _matches_llm_model(schema_cls: type[BlockSchema], query: str) -> bool:
return False
def _score_block(
block: AnyBlockSchema,
block_info: BlockInfo,
normalized_query: str,
) -> float:
if not normalized_query:
return 0.0
name = block_info.name.lower()
description = block_info.description.lower()
score = _score_primary_fields(name, description, normalized_query)
category_text = " ".join(
category.get("category", "").lower() for category in block_info.categories
)
score += _score_additional_field(category_text, normalized_query, 12, 6)
credentials_info = block.input_schema.get_credentials_fields_info().values()
provider_names = [
provider.value.lower()
for info in credentials_info
for provider in info.provider
]
provider_text = " ".join(provider_names)
score += _score_additional_field(provider_text, normalized_query, 15, 6)
if _matches_llm_model(block.input_schema, normalized_query):
score += 20
return score
def _score_library_agent(
agent: library_model.LibraryAgent,
normalized_query: str,
) -> float:
if not normalized_query:
return 0.0
name = agent.name.lower()
description = (agent.description or "").lower()
instructions = (agent.instructions or "").lower()
score = _score_primary_fields(name, description, normalized_query)
score += _score_additional_field(instructions, normalized_query, 15, 6)
score += _score_additional_field(
agent.creator_name.lower(), normalized_query, 10, 5
)
return score
def _score_store_agent(
agent: store_model.StoreAgent,
normalized_query: str,
) -> float:
if not normalized_query:
return 0.0
name = agent.agent_name.lower()
description = agent.description.lower()
sub_heading = agent.sub_heading.lower()
score = _score_primary_fields(name, description, normalized_query)
score += _score_additional_field(sub_heading, normalized_query, 12, 6)
score += _score_additional_field(agent.creator.lower(), normalized_query, 10, 5)
return score
def _score_primary_fields(name: str, description: str, query: str) -> float:
score = 0.0
if name == query:
score += 120
elif name.startswith(query):
score += 90
elif query in name:
score += 60
score += SequenceMatcher(None, name, query).ratio() * 50
if description:
if query in description:
score += 30
score += SequenceMatcher(None, description, query).ratio() * 25
return score
def _score_additional_field(
value: str,
query: str,
contains_weight: float,
similarity_weight: float,
) -> float:
if not value or not query:
return 0.0
score = 0.0
if query in value:
score += contains_weight
score += SequenceMatcher(None, value, query).ratio() * similarity_weight
return score
def _should_include_item(score: float, normalized_query: str) -> bool:
if not normalized_query:
return True
return score >= MIN_SCORE_FOR_FILTERED_RESULTS
def _get_item_name(item: SearchResultItem) -> str:
if isinstance(item, BlockInfo):
return item.name.lower()
if isinstance(item, library_model.LibraryAgent):
return item.name.lower()
return item.agent_name.lower()
@cached(ttl_seconds=3600)
def _get_all_providers() -> dict[ProviderName, Provider]:
providers: dict[ProviderName, Provider] = {}
@@ -329,13 +639,9 @@ def _get_all_providers() -> dict[ProviderName, Provider]:
return providers
@cached(ttl_seconds=3600)
async def get_suggested_blocks(count: int = 5) -> list[BlockInfo]:
global _suggested_blocks
if _suggested_blocks is not None and len(_suggested_blocks) >= count:
return _suggested_blocks[:count]
_suggested_blocks = []
suggested_blocks = []
# Sum the number of executions for each block type
# Prisma cannot group by nested relations, so we do a raw query
# Calculate the cutoff timestamp
@@ -376,7 +682,7 @@ async def get_suggested_blocks(count: int = 5) -> list[BlockInfo]:
# Sort blocks by execution count
blocks.sort(key=lambda x: x[1], reverse=True)
_suggested_blocks = [block[0] for block in blocks]
suggested_blocks = [block[0] for block in blocks]
# Return the top blocks
return _suggested_blocks[:count]
return suggested_blocks[:count]

View File

@@ -18,10 +18,17 @@ FilterType = Literal[
BlockType = Literal["all", "input", "action", "output"]
class SearchEntry(BaseModel):
search_query: str | None = None
filter: list[FilterType] | None = None
by_creator: list[str] | None = None
search_id: str | None = None
# Suggestions
class SuggestionsResponse(BaseModel):
otto_suggestions: list[str]
recent_searches: list[str]
recent_searches: list[SearchEntry]
providers: list[ProviderName]
top_blocks: list[BlockInfo]
@@ -32,7 +39,7 @@ class BlockCategoryResponse(BaseModel):
total_blocks: int
blocks: list[BlockInfo]
model_config = {"use_enum_values": False} # <== use enum names like "AI"
model_config = {"use_enum_values": False} # Use enum names like "AI"
# Input/Action/Output and see all for block categories
@@ -53,17 +60,11 @@ class ProviderResponse(BaseModel):
pagination: Pagination
class SearchBlocksResponse(BaseModel):
blocks: BlockResponse
total_block_count: int
total_integration_count: int
class SearchResponse(BaseModel):
items: list[BlockInfo | library_model.LibraryAgent | store_model.StoreAgent]
search_id: str
total_items: dict[FilterType, int]
page: int
more_pages: bool
pagination: Pagination
class CountResponse(BaseModel):

View File

@@ -6,10 +6,6 @@ from autogpt_libs.auth.dependencies import get_user_id, requires_user
import backend.server.v2.builder.db as builder_db
import backend.server.v2.builder.model as builder_model
import backend.server.v2.library.db as library_db
import backend.server.v2.library.model as library_model
import backend.server.v2.store.db as store_db
import backend.server.v2.store.model as store_model
from backend.integrations.providers import ProviderName
from backend.util.models import Pagination
@@ -45,7 +41,9 @@ def sanitize_query(query: str | None) -> str | None:
summary="Get Builder suggestions",
response_model=builder_model.SuggestionsResponse,
)
async def get_suggestions() -> builder_model.SuggestionsResponse:
async def get_suggestions(
user_id: Annotated[str, fastapi.Security(get_user_id)],
) -> builder_model.SuggestionsResponse:
"""
Get all suggestions for the Blocks Menu.
"""
@@ -55,11 +53,7 @@ async def get_suggestions() -> builder_model.SuggestionsResponse:
"Help me create a list",
"Help me feed my data to Google Maps",
],
recent_searches=[
"image generation",
"deepfake",
"competitor analysis",
],
recent_searches=await builder_db.get_recent_searches(user_id),
providers=[
ProviderName.TWITTER,
ProviderName.GITHUB,
@@ -147,7 +141,6 @@ async def get_providers(
)
# Not using post method because on frontend, orval doesn't support Infinite Query with POST method.
@router.get(
"/search",
summary="Builder search",
@@ -157,7 +150,7 @@ async def get_providers(
async def search(
user_id: Annotated[str, fastapi.Security(get_user_id)],
search_query: Annotated[str | None, fastapi.Query()] = None,
filter: Annotated[list[str] | None, fastapi.Query()] = None,
filter: Annotated[list[builder_model.FilterType] | None, fastapi.Query()] = None,
search_id: Annotated[str | None, fastapi.Query()] = None,
by_creator: Annotated[list[str] | None, fastapi.Query()] = None,
page: Annotated[int, fastapi.Query()] = 1,
@@ -176,69 +169,43 @@ async def search(
]
search_query = sanitize_query(search_query)
# Blocks&Integrations
blocks = builder_model.SearchBlocksResponse(
blocks=builder_model.BlockResponse(
blocks=[],
pagination=Pagination.empty(),
),
total_block_count=0,
total_integration_count=0,
# Get all possible results
cached_results = await builder_db.get_sorted_search_results(
user_id=user_id,
search_query=search_query,
filters=filter,
by_creator=by_creator,
)
if "blocks" in filter or "integrations" in filter:
blocks = builder_db.search_blocks(
include_blocks="blocks" in filter,
include_integrations="integrations" in filter,
query=search_query or "",
page=page,
page_size=page_size,
)
# Library Agents
my_agents = library_model.LibraryAgentResponse(
agents=[],
pagination=Pagination.empty(),
# Paginate results
total_combined_items = len(cached_results.items)
pagination = Pagination(
total_items=total_combined_items,
total_pages=(total_combined_items + page_size - 1) // page_size,
current_page=page,
page_size=page_size,
)
if "my_agents" in filter:
my_agents = await library_db.list_library_agents(
user_id=user_id,
search_term=search_query,
page=page,
page_size=page_size,
)
# Marketplace Agents
marketplace_agents = store_model.StoreAgentsResponse(
agents=[],
pagination=Pagination.empty(),
)
if "marketplace_agents" in filter:
marketplace_agents = await store_db.get_store_agents(
creators=by_creator,
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_items = cached_results.items[start_idx:end_idx]
# Update the search entry by id
search_id = await builder_db.update_search(
user_id,
builder_model.SearchEntry(
search_query=search_query,
page=page,
page_size=page_size,
)
more_pages = False
if (
blocks.blocks.pagination.current_page < blocks.blocks.pagination.total_pages
or my_agents.pagination.current_page < my_agents.pagination.total_pages
or marketplace_agents.pagination.current_page
< marketplace_agents.pagination.total_pages
):
more_pages = True
filter=filter,
by_creator=by_creator,
search_id=search_id,
),
)
return builder_model.SearchResponse(
items=blocks.blocks.blocks + my_agents.agents + marketplace_agents.agents,
total_items={
"blocks": blocks.total_block_count,
"integrations": blocks.total_integration_count,
"marketplace_agents": marketplace_agents.pagination.total_items,
"my_agents": my_agents.pagination.total_items,
},
page=page,
more_pages=more_pages,
items=paginated_items,
search_id=search_id,
total_items=cached_results.total_items,
pagination=pagination,
)

View File

@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(
tags=["executions", "review", "private"],
tags=["v2", "executions", "review"],
dependencies=[Security(autogpt_auth_lib.requires_user)],
)

View File

@@ -5,6 +5,13 @@ from tiktoken import encoding_for_model
from backend.util import json
# ---------------------------------------------------------------------------#
# CONSTANTS #
# ---------------------------------------------------------------------------#
# Message prefixes for important system messages that should be protected during compression
MAIN_OBJECTIVE_PREFIX = "[Main Objective Prompt]: "
# ---------------------------------------------------------------------------#
# INTERNAL UTILITIES #
# ---------------------------------------------------------------------------#
@@ -63,6 +70,55 @@ def _msg_tokens(msg: dict, enc) -> int:
return WRAPPER + content_tokens + tool_call_tokens
def _is_tool_message(msg: dict) -> bool:
"""Check if a message contains tool calls or results that should be protected."""
content = msg.get("content")
# Check for Anthropic-style tool messages
if isinstance(content, list) and any(
isinstance(item, dict) and item.get("type") in ("tool_use", "tool_result")
for item in content
):
return True
# Check for OpenAI-style tool calls in the message
if "tool_calls" in msg or msg.get("role") == "tool":
return True
return False
def _is_objective_message(msg: dict) -> bool:
"""Check if a message contains objective/system prompts that should be absolutely protected."""
content = msg.get("content", "")
if isinstance(content, str):
# Protect any message with the main objective prefix
return content.startswith(MAIN_OBJECTIVE_PREFIX)
return False
def _truncate_tool_message_content(msg: dict, enc, max_tokens: int) -> None:
"""
Carefully truncate tool message content while preserving tool structure.
Only truncates tool_result content, leaves tool_use intact.
"""
content = msg.get("content")
if not isinstance(content, list):
return
for item in content:
# Only process tool_result items, leave tool_use blocks completely intact
if not (isinstance(item, dict) and item.get("type") == "tool_result"):
continue
result_content = item.get("content", "")
if (
isinstance(result_content, str)
and _tok_len(result_content, enc) > max_tokens
):
item["content"] = _truncate_middle_tokens(result_content, enc, max_tokens)
def _truncate_middle_tokens(text: str, enc, max_tok: int) -> str:
"""
Return *text* shortened to ≈max_tok tokens by keeping the head & tail
@@ -140,13 +196,21 @@ def compress_prompt(
return sum(_msg_tokens(m, enc) for m in msgs)
original_token_count = total_tokens()
if original_token_count + reserve <= target_tokens:
return msgs
# ---- STEP 0 : normalise content --------------------------------------
# Convert non-string payloads to strings so token counting is coherent.
for m in msgs[1:-1]: # keep the first & last intact
for i, m in enumerate(msgs):
if not isinstance(m.get("content"), str) and m.get("content") is not None:
if _is_tool_message(m):
continue
# Keep first and last messages intact (unless they're tool messages)
if i == 0 or i == len(msgs) - 1:
continue
# Reasonable 20k-char ceiling prevents pathological blobs
content_str = json.dumps(m["content"], separators=(",", ":"))
if len(content_str) > 20_000:
@@ -157,34 +221,45 @@ def compress_prompt(
cap = start_cap
while total_tokens() + reserve > target_tokens and cap >= floor_cap:
for m in msgs[1:-1]: # keep first & last intact
if _tok_len(m.get("content") or "", enc) > cap:
m["content"] = _truncate_middle_tokens(m["content"], enc, cap)
if _is_tool_message(m):
# For tool messages, only truncate tool result content, preserve structure
_truncate_tool_message_content(m, enc, cap)
continue
if _is_objective_message(m):
# Never truncate objective messages - they contain the core task
continue
content = m.get("content") or ""
if _tok_len(content, enc) > cap:
m["content"] = _truncate_middle_tokens(content, enc, cap)
cap //= 2 # tighten the screw
# ---- STEP 2 : middle-out deletion -----------------------------------
while total_tokens() + reserve > target_tokens and len(msgs) > 2:
# Identify all deletable messages (not first/last, not tool messages, not objective messages)
deletable_indices = []
for i in range(1, len(msgs) - 1): # Skip first and last
if not _is_tool_message(msgs[i]) and not _is_objective_message(msgs[i]):
deletable_indices.append(i)
if not deletable_indices:
break # nothing more we can drop
# Delete from center outward - find the index closest to center
centre = len(msgs) // 2
# Build a symmetrical centre-out index walk: centre, centre+1, centre-1, ...
order = [centre] + [
i
for pair in zip(range(centre + 1, len(msgs) - 1), range(centre - 1, 0, -1))
for i in pair
]
removed = False
for i in order:
msg = msgs[i]
if "tool_calls" in msg or msg.get("role") == "tool":
continue # protect tool shells
del msgs[i]
removed = True
break
if not removed: # nothing more we can drop
break
to_delete = min(deletable_indices, key=lambda i: abs(i - centre))
del msgs[to_delete]
# ---- STEP 3 : final safety-net trim on first & last ------------------
cap = start_cap
while total_tokens() + reserve > target_tokens and cap >= floor_cap:
for idx in (0, -1): # first and last
if _is_tool_message(msgs[idx]):
# For tool messages at first/last position, truncate tool result content only
_truncate_tool_message_content(msgs[idx], enc, cap)
continue
text = msgs[idx].get("content") or ""
if _tok_len(text, enc) > cap:
msgs[idx]["content"] = _truncate_middle_tokens(text, enc, cap)

View File

@@ -185,6 +185,12 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="Number of top blocks with most errors to show when no blocks exceed threshold (0 to disable).",
)
# Execution Accuracy Monitoring
execution_accuracy_check_interval_hours: int = Field(
default=24,
description="Interval in hours between execution accuracy alert checks.",
)
model_config = SettingsConfigDict(
env_file=".env",
extra="allow",

View File

@@ -0,0 +1,15 @@
-- Create BuilderSearchHistory table
CREATE TABLE "BuilderSearchHistory" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"searchQuery" TEXT NOT NULL,
"filter" TEXT[] DEFAULT ARRAY[]::TEXT[],
"byCreator" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "BuilderSearchHistory_pkey" PRIMARY KEY ("id")
);
-- Define User foreign relation
ALTER TABLE "BuilderSearchHistory" ADD CONSTRAINT "BuilderSearchHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -53,6 +53,7 @@ model User {
Profile Profile[]
UserOnboarding UserOnboarding?
BuilderSearchHistory BuilderSearchHistory[]
StoreListings StoreListing[]
StoreListingReviews StoreListingReview[]
StoreVersionsReviewed StoreListingVersion[]
@@ -114,6 +115,19 @@ model UserOnboarding {
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model BuilderSearchHistory {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
searchQuery String
filter String[] @default([])
byCreator String[] @default([])
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// This model describes the Agent Graph/Flow (Multi Agent System).
model AgentGraph {
id String @default(uuid())

View File

@@ -41,12 +41,6 @@ export default defineConfig({
useInfiniteQueryParam: "page",
},
},
"getV2List presets": {
query: {
useInfinite: true,
useInfiniteQueryParam: "page",
},
},
"getV1List graph executions": {
query: {
useInfinite: true,

View File

@@ -82,7 +82,7 @@
"lodash": "4.17.21",
"lucide-react": "0.552.0",
"moment": "2.30.1",
"next": "15.4.8",
"next": "15.4.10",
"next-themes": "0.4.6",
"nuqs": "2.7.2",
"party-js": "2.2.0",

View File

@@ -16,7 +16,7 @@ importers:
version: 5.2.2(react-hook-form@7.66.0(react@18.3.1))
'@next/third-parties':
specifier: 15.4.6
version: 15.4.6(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 15.4.6(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@phosphor-icons/react':
specifier: 2.1.10
version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -88,7 +88,7 @@ importers:
version: 5.24.13(@rjsf/utils@5.24.13(react@18.3.1))
'@sentry/nextjs':
specifier: 10.27.0
version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))
version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))
'@supabase/ssr':
specifier: 0.7.0
version: 0.7.0(@supabase/supabase-js@2.78.0)
@@ -106,10 +106,10 @@ importers:
version: 0.2.4
'@vercel/analytics':
specifier: 1.5.0
version: 1.5.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 1.5.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@vercel/speed-insights':
specifier: 1.2.0
version: 1.2.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 1.2.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@xyflow/react':
specifier: 12.9.2
version: 12.9.2(@types/react@18.3.17)(immer@10.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -148,7 +148,7 @@ importers:
version: 12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
geist:
specifier: 1.5.1
version: 1.5.1(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
version: 1.5.1(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
highlight.js:
specifier: 11.11.1
version: 11.11.1
@@ -171,14 +171,14 @@ importers:
specifier: 2.30.1
version: 2.30.1
next:
specifier: 15.4.8
version: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: 15.4.10
version: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-themes:
specifier: 0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: 2.7.2
version: 2.7.2(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 2.7.2(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
party-js:
specifier: 2.2.0
version: 2.2.0
@@ -284,7 +284,7 @@ importers:
version: 9.1.5(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))
'@storybook/nextjs':
specifier: 9.1.5
version: 9.1.5(esbuild@0.25.9)(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.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))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))
version: 9.1.5(esbuild@0.25.9)(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.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))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))
'@tanstack/eslint-plugin-query':
specifier: 5.91.2
version: 5.91.2(eslint@8.57.1)(typescript@5.9.3)
@@ -1602,8 +1602,8 @@ packages:
'@neoconfetti/react@1.0.0':
resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==}
'@next/env@15.4.8':
resolution: {integrity: sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==}
'@next/env@15.4.10':
resolution: {integrity: sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg==}
'@next/eslint-plugin-next@15.5.2':
resolution: {integrity: sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q==}
@@ -5920,8 +5920,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.4.8:
resolution: {integrity: sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==}
next@15.4.10:
resolution: {integrity: sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@@ -9003,7 +9003,7 @@ snapshots:
'@neoconfetti/react@1.0.0': {}
'@next/env@15.4.8': {}
'@next/env@15.4.10': {}
'@next/eslint-plugin-next@15.5.2':
dependencies:
@@ -9033,9 +9033,9 @@ snapshots:
'@next/swc-win32-x64-msvc@15.4.8':
optional: true
'@next/third-parties@15.4.6(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@next/third-parties@15.4.6(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies:
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
third-party-capital: 1.0.20
@@ -10267,7 +10267,7 @@ snapshots:
'@sentry/core@10.27.0': {}
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))':
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.37.0
@@ -10280,7 +10280,7 @@ snapshots:
'@sentry/react': 10.27.0(react@18.3.1)
'@sentry/vercel-edge': 10.27.0
'@sentry/webpack-plugin': 4.3.0(webpack@5.101.3(esbuild@0.25.9))
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
resolve: 1.22.8
rollup: 4.52.2
stacktrace-parser: 0.1.11
@@ -10642,7 +10642,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@storybook/nextjs@9.1.5(esbuild@0.25.9)(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.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))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))':
'@storybook/nextjs@9.1.5(esbuild@0.25.9)(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.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))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))':
dependencies:
'@babel/core': 7.28.4
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4)
@@ -10666,7 +10666,7 @@ snapshots:
css-loader: 6.11.0(webpack@5.101.3(esbuild@0.25.9))
image-size: 2.0.2
loader-utils: 3.3.1
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
node-polyfill-webpack-plugin: 2.0.1(webpack@5.101.3(esbuild@0.25.9))
postcss: 8.5.6
postcss-loader: 8.2.0(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.3(esbuild@0.25.9))
@@ -11271,14 +11271,14 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vercel/analytics@1.5.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@vercel/analytics@1.5.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
optionalDependencies:
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@vercel/speed-insights@1.2.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@vercel/speed-insights@1.2.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
optionalDependencies:
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@vitest/expect@3.2.4':
@@ -12954,9 +12954,9 @@ snapshots:
functions-have-names@1.2.3: {}
geist@1.5.1(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
geist@1.5.1(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
dependencies:
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
gensync@1.0.0-beta.2: {}
@@ -14226,9 +14226,9 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 15.4.8
'@next/env': 15.4.10
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001741
postcss: 8.4.31
@@ -14321,12 +14321,12 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.7.2(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
nuqs@2.7.2(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.3.1
optionalDependencies:
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
oas-kit-common@1.0.8:
dependencies:

View File

@@ -1,6 +1,16 @@
"use client";
import { useState, useEffect } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
@@ -18,9 +28,12 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
import {
usePostV2GenerateExecutionAnalytics,
useGetV2GetExecutionAnalyticsConfiguration,
useGetV2GetExecutionAccuracyTrendsAndAlerts,
} from "@/app/api/__generated__/endpoints/admin/admin";
import type { ExecutionAnalyticsRequest } from "@/app/api/__generated__/models/executionAnalyticsRequest";
import type { ExecutionAnalyticsResponse } from "@/app/api/__generated__/models/executionAnalyticsResponse";
import type { AccuracyTrendsResponse } from "@/app/api/__generated__/models/accuracyTrendsResponse";
import type { AccuracyLatestData } from "@/app/api/__generated__/models/accuracyLatestData";
// Use the generated type with minimal adjustment for form handling
interface FormData extends Omit<ExecutionAnalyticsRequest, "created_after"> {
@@ -33,8 +46,133 @@ export function ExecutionAnalyticsForm() {
const [results, setResults] = useState<ExecutionAnalyticsResponse | null>(
null,
);
const [trendsData, setTrendsData] = useState<AccuracyTrendsResponse | null>(
null,
);
const { toast } = useToast();
// State for accuracy trends query parameters
const [accuracyParams, setAccuracyParams] = useState<{
graph_id: string;
user_id?: string;
days_back: number;
drop_threshold: number;
include_historical?: boolean;
} | null>(null);
// Use the generated API client for accuracy trends (GET)
const { data: accuracyApiResponse, error: accuracyError } =
useGetV2GetExecutionAccuracyTrendsAndAlerts(
accuracyParams || {
graph_id: "",
days_back: 30,
drop_threshold: 10.0,
include_historical: false,
},
{
query: {
enabled: !!accuracyParams?.graph_id,
},
},
);
// Update local state when data changes and handle success/error
useEffect(() => {
if (accuracyError) {
console.error("Failed to fetch trends:", accuracyError);
toast({
title: "Trends Error",
description:
(accuracyError as any)?.message || "Failed to fetch accuracy trends",
variant: "destructive",
});
return;
}
const data = accuracyApiResponse?.data;
if (data && "latest_data" in data) {
setTrendsData(data);
// Check for alerts
if (data.alert) {
toast({
title: "🚨 Accuracy Alert Detected",
description: `${data.alert.drop_percent.toFixed(1)}% accuracy drop detected for this agent`,
variant: "destructive",
});
}
}
}, [accuracyApiResponse, accuracyError, toast]);
// Chart component for accuracy trends
function AccuracyChart({ data }: { data: AccuracyLatestData[] }) {
const chartData = data.map((item) => ({
date: new Date(item.date).toLocaleDateString(),
"Daily Score": item.daily_score,
"3-Day Avg": item.three_day_avg,
"7-Day Avg": item.seven_day_avg,
"14-Day Avg": item.fourteen_day_avg,
}));
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart
data={chartData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} />
<Tooltip
formatter={(value) => [`${Number(value).toFixed(2)}%`, ""]}
/>
<Legend />
<Line
type="monotone"
dataKey="Daily Score"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="3-Day Avg"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="7-Day Avg"
stroke="#f59e0b"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="14-Day Avg"
stroke="#8b5cf6"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
);
}
// Function to fetch accuracy trends using generated API client
const fetchAccuracyTrends = (graphId: string, userId?: string) => {
if (!graphId.trim()) return;
setAccuracyParams({
graph_id: graphId.trim(),
user_id: userId?.trim() || undefined,
days_back: 30,
drop_threshold: 10.0,
include_historical: showAccuracyChart, // Include historical data when chart is enabled
});
};
// Fetch configuration from API
const {
data: config,
@@ -50,6 +188,7 @@ export function ExecutionAnalyticsForm() {
}
const result = res.data;
setResults(result);
toast({
title: "Analytics Generated",
description: `Processed ${result.processed_executions} executions. ${result.successful_analytics} successful, ${result.failed_analytics} failed, ${result.skipped_executions} skipped.`,
@@ -58,11 +197,21 @@ export function ExecutionAnalyticsForm() {
},
onError: (error: any) => {
console.error("Analytics generation error:", error);
const errorMessage =
error?.message || error?.detail || "An unexpected error occurred";
const isOpenAIError = errorMessage.includes(
"OpenAI API key not configured",
);
toast({
title: "Analytics Generation Failed",
description:
error?.message || error?.detail || "An unexpected error occurred",
variant: "destructive",
title: isOpenAIError
? "Analytics Generation Skipped"
: "Analytics Generation Failed",
description: isOpenAIError
? "Analytics generation requires OpenAI configuration, but accuracy trends are still available above."
: errorMessage,
variant: isOpenAIError ? "default" : "destructive",
});
},
},
@@ -77,6 +226,9 @@ export function ExecutionAnalyticsForm() {
user_prompt: "", // Will use config default when empty
});
// State for accuracy trends chart toggle
const [showAccuracyChart, setShowAccuracyChart] = useState(true);
// Update form defaults when config loads
useEffect(() => {
if (config?.data && config.status === 200 && !formData.model_name) {
@@ -101,6 +253,11 @@ export function ExecutionAnalyticsForm() {
setResults(null);
// Fetch accuracy trends if chart is enabled
if (showAccuracyChart) {
fetchAccuracyTrends(formData.graph_id, formData.user_id || undefined);
}
// Prepare the request payload
const payload: ExecutionAnalyticsRequest = {
graph_id: formData.graph_id.trim(),
@@ -262,6 +419,18 @@ export function ExecutionAnalyticsForm() {
</Label>
</div>
{/* Show Accuracy Chart Checkbox */}
<div className="flex items-center space-x-2">
<Checkbox
id="show_accuracy_chart"
checked={showAccuracyChart}
onCheckedChange={(checked) => setShowAccuracyChart(!!checked)}
/>
<Label htmlFor="show_accuracy_chart" className="text-sm">
Show accuracy trends chart and historical data visualization
</Label>
</div>
{/* Custom System Prompt */}
<div className="space-y-2">
<Label htmlFor="system_prompt">
@@ -370,6 +539,98 @@ export function ExecutionAnalyticsForm() {
</div>
</form>
{/* Accuracy Trends Display */}
{trendsData && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Execution Accuracy Trends</h3>
{/* Alert Section */}
{trendsData.alert && (
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 p-4">
<div className="flex items-start">
<span className="text-2xl">🚨</span>
<div className="ml-3 space-y-2">
<h4 className="text-lg font-semibold text-red-800">
Accuracy Alert Detected
</h4>
<p className="text-red-700">
<strong>
{trendsData.alert.drop_percent.toFixed(1)}% accuracy drop
</strong>{" "}
detected for agent{" "}
<code className="rounded bg-red-100 px-1 text-sm">
{formData.graph_id}
</code>
</p>
<div className="space-y-1 text-sm text-red-600">
<p>
3-day average:{" "}
<strong>
{trendsData.alert.three_day_avg.toFixed(2)}%
</strong>
</p>
<p>
7-day average:{" "}
<strong>
{trendsData.alert.seven_day_avg.toFixed(2)}%
</strong>
</p>
<p>
Detected at:{" "}
<strong>
{new Date(
trendsData.alert.detected_at,
).toLocaleString()}
</strong>
</p>
</div>
</div>
</div>
</div>
)}
{/* Latest Data Summary */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-blue-600">
{trendsData.latest_data.daily_score?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">Daily Score</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-green-600">
{trendsData.latest_data.three_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">3-Day Avg</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-orange-600">
{trendsData.latest_data.seven_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">7-Day Avg</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-purple-600">
{trendsData.latest_data.fourteen_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">14-Day Avg</div>
</div>
</div>
{/* Chart Section - only show when toggle is enabled and historical data exists */}
{showAccuracyChart && trendsData?.historical_data && (
<div className="mt-6">
<h4 className="mb-4 text-lg font-semibold">
Execution Accuracy Trends Chart
</h4>
<div className="rounded-lg border bg-white p-6">
<AccuracyChart data={trendsData.historical_data} />
</div>
</div>
)}
</div>
)}
{results && <AnalyticsResultsTable results={results} />}
</div>
);

View File

@@ -17,12 +17,13 @@ function ExecutionAnalyticsDashboard() {
</div>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Analytics Generation</h2>
<h2 className="mb-4 text-xl font-semibold">
Execution Analytics & Accuracy Monitoring
</h2>
<p className="mb-6 text-gray-600">
This tool will identify completed executions missing activity
summaries or success scores and generate them using AI. Only
executions that meet the criteria and are missing these fields will
be processed.
Generate missing activity summaries and success scores for agent
executions. After generation, accuracy trends and alerts will
automatically be displayed to help monitor agent health over time.
</p>
<Suspense

View File

@@ -1,4 +1,4 @@
import { OAuthPopupResultMessage } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal";
import { NextResponse } from "next/server";
// This route is intended to be used as the callback for integration OAuth flows,

View File

@@ -18,6 +18,7 @@ import { parseAsString, useQueryStates } from "nuqs";
import { CustomControls } from "./components/CustomControl";
import { FloatingSafeModeToggle } from "@/components/molecules/FloatingSafeModeToggle/FloatingSafeModeToggle";
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { okData } from "@/app/api/helpers";
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
import { resolveCollisions } from "./helpers/resolve-collision";
@@ -33,7 +34,7 @@ export const Flow = () => {
{},
{
query: {
select: okData,
select: okData<GraphModel>,
enabled: !!flowID,
},
},

View File

@@ -1,24 +1,25 @@
import { useCallback } from "react";
import { useReactFlow } from "@xyflow/react";
import { Key, storage } from "@/services/storage/local-storage";
import { v4 as uuidv4 } from "uuid";
import { useNodeStore } from "../../../stores/nodeStore";
import { useEdgeStore } from "../../../stores/edgeStore";
import { CustomNode } from "../nodes/CustomNode/CustomNode";
import { CustomEdge } from "../edges/CustomEdge";
import { useToast } from "@/components/molecules/Toast/use-toast";
interface CopyableData {
nodes: CustomNode[];
edges: CustomEdge[];
}
const CLIPBOARD_PREFIX = "autogpt-flow-data:";
export function useCopyPaste() {
// Only use useReactFlow for viewport (not managed by stores)
const { getViewport } = useReactFlow();
const { toast } = useToast();
const handleCopyPaste = useCallback(
(event: KeyboardEvent) => {
// Prevent copy/paste if any modal is open or if the focus is on an input element
const activeElement = document.activeElement;
const isInputField =
activeElement?.tagName === "INPUT" ||
@@ -28,7 +29,6 @@ export function useCopyPaste() {
if (isInputField) return;
if (event.ctrlKey || event.metaKey) {
// COPY: Ctrl+C or Cmd+C
if (event.key === "c" || event.key === "C") {
const { nodes } = useNodeStore.getState();
const { edges } = useEdgeStore.getState();
@@ -53,81 +53,102 @@ export function useCopyPaste() {
edges: selectedEdges,
};
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
const clipboardText = `${CLIPBOARD_PREFIX}${JSON.stringify(copiedData)}`;
navigator.clipboard
.writeText(clipboardText)
.then(() => {
toast({
title: "Copied successfully",
description: `${selectedNodes.length} node(s) copied to clipboard`,
});
})
.catch((error) => {
console.error("Failed to copy to clipboard:", error);
});
}
// PASTE: Ctrl+V or Cmd+V
if (event.key === "v" || event.key === "V") {
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
if (copiedDataString) {
const copiedData = JSON.parse(copiedDataString) as CopyableData;
const oldToNewIdMap: Record<string, string> = {};
navigator.clipboard
.readText()
.then((clipboardText) => {
if (!clipboardText.startsWith(CLIPBOARD_PREFIX)) {
return; // Not our data, ignore
}
// Get fresh viewport values at paste time to ensure correct positioning
const { x, y, zoom } = getViewport();
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};
const jsonString = clipboardText.slice(CLIPBOARD_PREFIX.length);
const copiedData = JSON.parse(jsonString) as CopyableData;
const oldToNewIdMap: Record<string, string> = {};
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
copiedData.nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x);
maxY = Math.max(maxY, node.position.y);
});
const offsetX = viewportCenter.x - (minX + maxX) / 2;
const offsetY = viewportCenter.y - (minY + maxY) / 2;
// Deselect existing nodes first
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({ ...node, selected: false })),
}));
// Create and add new nodes with UNIQUE IDs using UUID
copiedData.nodes.forEach((node) => {
const newNodeId = uuidv4();
oldToNewIdMap[node.id] = newNodeId;
const newNode: CustomNode = {
...node,
id: newNodeId,
selected: true,
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
const { x, y, zoom } = getViewport();
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};
useNodeStore.getState().addNode(newNode);
});
// Add edges with updated source/target IDs
const { addEdge } = useEdgeStore.getState();
copiedData.edges.forEach((edge) => {
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
addEdge({
source: newSourceId,
target: newTargetId,
sourceHandle: edge.sourceHandle ?? "",
targetHandle: edge.targetHandle ?? "",
data: {
...edge.data,
},
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
copiedData.nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x);
maxY = Math.max(maxY, node.position.y);
});
const offsetX = viewportCenter.x - (minX + maxX) / 2;
const offsetY = viewportCenter.y - (minY + maxY) / 2;
// Deselect existing nodes first
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({
...node,
selected: false,
})),
}));
// Create and add new nodes with UNIQUE IDs using UUID
copiedData.nodes.forEach((node) => {
const newNodeId = uuidv4();
oldToNewIdMap[node.id] = newNodeId;
const newNode: CustomNode = {
...node,
id: newNodeId,
selected: true,
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
};
useNodeStore.getState().addNode(newNode);
});
// Add edges with updated source/target IDs
const { addEdge } = useEdgeStore.getState();
copiedData.edges.forEach((edge) => {
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
addEdge({
source: newSourceId,
target: newTargetId,
sourceHandle: edge.sourceHandle ?? "",
targetHandle: edge.targetHandle ?? "",
data: {
...edge.data,
},
});
});
})
.catch((error) => {
console.error("Failed to read from clipboard:", error);
});
}
}
}
},
[getViewport],
[getViewport, toast],
);
return handleCopyPaste;

View File

@@ -42,11 +42,12 @@ export const useFlow = () => {
const setBlockMenuOpen = useControlPanelStore(
useShallow((state) => state.setBlockMenuOpen),
);
const [{ flowID, flowVersion, flowExecutionID }] = useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
flowExecutionID: parseAsString,
});
const [{ flowID, flowVersion, flowExecutionID }, setQueryStates] =
useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
flowExecutionID: parseAsString,
});
const { data: executionDetails } = useGetV1GetExecutionDetails(
flowID || "",
@@ -102,6 +103,9 @@ export const useFlow = () => {
// load graph schemas
useEffect(() => {
if (graph) {
setQueryStates({
flowVersion: graph.version ?? 1,
});
setGraphSchemas(
graph.input_schema as Record<string, any> | null,
graph.credentials_input_schema as Record<string, any> | null,

View File

@@ -1,7 +1,7 @@
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { useGetV2BuilderSearchInfinite } from "@/app/api/__generated__/endpoints/store/store";
import { SearchResponse } from "@/app/api/__generated__/models/searchResponse";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAddAgentToBuilder } from "../hooks/useAddAgentToBuilder";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { getV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
@@ -9,16 +9,27 @@ import {
getGetV2ListLibraryAgentsQueryKey,
usePostV2AddMarketplaceAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import { getGetV2GetBuilderItemCountsQueryKey } from "@/app/api/__generated__/endpoints/default/default";
import {
getGetV2GetBuilderItemCountsQueryKey,
getGetV2GetBuilderSuggestionsQueryKey,
} from "@/app/api/__generated__/endpoints/default/default";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useToast } from "@/components/molecules/Toast/use-toast";
import * as Sentry from "@sentry/nextjs";
export const useBlockMenuSearch = () => {
const { searchQuery } = useBlockMenuStore();
const { searchQuery, searchId, setSearchId } = useBlockMenuStore();
const { toast } = useToast();
const { addAgentToBuilder, addLibraryAgentToBuilder } =
useAddAgentToBuilder();
const queryClient = getQueryClient();
const resetSearchSession = useCallback(() => {
setSearchId(undefined);
queryClient.invalidateQueries({
queryKey: getGetV2GetBuilderSuggestionsQueryKey(),
});
}, [queryClient, setSearchId]);
const [addingLibraryAgentId, setAddingLibraryAgentId] = useState<
string | null
@@ -38,13 +49,19 @@ export const useBlockMenuSearch = () => {
page: 1,
page_size: 8,
search_query: searchQuery,
search_id: searchId,
},
{
query: {
getNextPageParam: (lastPage, allPages) => {
const pagination = lastPage.data as SearchResponse;
const isMore = pagination.more_pages;
return isMore ? allPages.length + 1 : undefined;
getNextPageParam: (lastPage) => {
const response = lastPage.data as SearchResponse;
const { pagination } = response;
if (!pagination) {
return undefined;
}
const { current_page, total_pages } = pagination;
return current_page < total_pages ? current_page + 1 : undefined;
},
},
},
@@ -53,7 +70,6 @@ export const useBlockMenuSearch = () => {
const { mutateAsync: addMarketplaceAgent } = usePostV2AddMarketplaceAgent({
mutation: {
onSuccess: () => {
const queryClient = getQueryClient();
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
@@ -75,6 +91,24 @@ export const useBlockMenuSearch = () => {
},
});
useEffect(() => {
if (!searchData?.pages?.length) {
return;
}
const latestPage = searchData.pages[searchData.pages.length - 1];
const response = latestPage?.data as SearchResponse;
if (response?.search_id && response.search_id !== searchId) {
setSearchId(response.search_id);
}
}, [searchData, searchId, setSearchId]);
useEffect(() => {
if (searchId && !searchQuery) {
resetSearchSession();
}
}, [resetSearchSession, searchId, searchQuery]);
const allSearchData =
searchData?.pages?.flatMap((page) => {
const response = page.data as SearchResponse;

View File

@@ -1,30 +1,32 @@
import { debounce } from "lodash";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { getGetV2GetBuilderSuggestionsQueryKey } from "@/app/api/__generated__/endpoints/default/default";
const SEARCH_DEBOUNCE_MS = 300;
export const useBlockMenuSearchBar = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState("");
const { setSearchQuery, setSearchId, searchId, searchQuery } =
useBlockMenuStore();
const { setSearchQuery, setSearchId, searchQuery } = useBlockMenuStore();
const queryClient = getQueryClient();
const searchIdRef = useRef(searchId);
useEffect(() => {
searchIdRef.current = searchId;
}, [searchId]);
const clearSearchSession = useCallback(() => {
setSearchId(undefined);
queryClient.invalidateQueries({
queryKey: getGetV2GetBuilderSuggestionsQueryKey(),
});
}, [queryClient, setSearchId]);
const debouncedSetSearchQuery = useCallback(
debounce((value: string) => {
setSearchQuery(value);
if (value.length === 0) {
setSearchId(undefined);
} else if (!searchIdRef.current) {
setSearchId(crypto.randomUUID());
clearSearchSession();
}
}, SEARCH_DEBOUNCE_MS),
[setSearchQuery, setSearchId],
[clearSearchSession, setSearchQuery],
);
useEffect(() => {
@@ -36,13 +38,13 @@ export const useBlockMenuSearchBar = () => {
const handleClear = () => {
setLocalQuery("");
setSearchQuery("");
setSearchId(undefined);
clearSearchSession();
debouncedSetSearchQuery.cancel();
};
useEffect(() => {
setLocalQuery(searchQuery);
}, []);
}, [searchQuery]);
return {
handleClear,

View File

@@ -0,0 +1,109 @@
import React, { useEffect, useRef, useState } from "react";
import { ArrowLeftIcon, ArrowRightIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
interface HorizontalScrollAreaProps {
children: React.ReactNode;
wrapperClassName?: string;
scrollContainerClassName?: string;
scrollAmount?: number;
dependencyList?: React.DependencyList;
}
const defaultDependencies: React.DependencyList = [];
const baseScrollClasses =
"flex gap-2 overflow-x-auto px-8 [scrollbar-width:none] [-ms-overflow-style:'none'] [&::-webkit-scrollbar]:hidden";
export const HorizontalScroll: React.FC<HorizontalScrollAreaProps> = ({
children,
wrapperClassName,
scrollContainerClassName,
scrollAmount = 300,
dependencyList = defaultDependencies,
}) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const scrollByDelta = (delta: number) => {
if (!scrollRef.current) {
return;
}
scrollRef.current.scrollBy({ left: delta, behavior: "smooth" });
};
const updateScrollState = () => {
const element = scrollRef.current;
if (!element) {
setCanScrollLeft(false);
setCanScrollRight(false);
return;
}
setCanScrollLeft(element.scrollLeft > 0);
setCanScrollRight(
Math.ceil(element.scrollLeft + element.clientWidth) < element.scrollWidth,
);
};
useEffect(() => {
updateScrollState();
const element = scrollRef.current;
if (!element) {
return;
}
const handleScroll = () => updateScrollState();
element.addEventListener("scroll", handleScroll);
window.addEventListener("resize", handleScroll);
return () => {
element.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
};
}, dependencyList);
return (
<div className={wrapperClassName}>
<div className="group relative">
<div
ref={scrollRef}
className={cn(baseScrollClasses, scrollContainerClassName)}
>
{children}
</div>
{canScrollLeft && (
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-white via-white/80 to-white/0" />
)}
{canScrollRight && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white via-white/80 to-white/0" />
)}
{canScrollLeft && (
<button
type="button"
aria-label="Scroll left"
className="pointer-events-none absolute left-2 top-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
onClick={() => scrollByDelta(-scrollAmount)}
>
<ArrowLeftIcon
size={28}
className="rounded-full bg-zinc-700 p-1 text-white drop-shadow"
weight="light"
/>
</button>
)}
{canScrollRight && (
<button
type="button"
aria-label="Scroll right"
className="pointer-events-none absolute right-2 top-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
onClick={() => scrollByDelta(scrollAmount)}
>
<ArrowRightIcon
size={28}
className="rounded-full bg-zinc-700 p-1 text-white drop-shadow"
weight="light"
/>
</button>
)}
</div>
</div>
);
};

View File

@@ -6,10 +6,15 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { DefaultStateType } from "../types";
import { SearchHistoryChip } from "../SearchHistoryChip";
import { HorizontalScroll } from "../HorizontalScroll";
export const SuggestionContent = () => {
const { setIntegration, setDefaultState } = useBlockMenuStore();
const { setIntegration, setDefaultState, setSearchQuery, setSearchId } =
useBlockMenuStore();
const { data, isLoading, isError, error, refetch } = useSuggestionContent();
const suggestions = data?.suggestions;
const hasRecentSearches = (suggestions?.recent_searches?.length ?? 0) > 0;
if (isError) {
return (
@@ -29,11 +34,45 @@ export const SuggestionContent = () => {
);
}
const suggestions = data?.suggestions;
return (
<div className={blockMenuContainerStyle}>
<div className="w-full space-y-6 pb-4">
{/* Recent searches */}
{hasRecentSearches && (
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Recent searches
</p>
<HorizontalScroll
wrapperClassName="-mx-8"
scrollContainerClassName="flex gap-2 overflow-x-auto px-8 [scrollbar-width:none] [-ms-overflow-style:'none'] [&::-webkit-scrollbar]:hidden"
dependencyList={[
suggestions?.recent_searches?.length ?? 0,
isLoading,
]}
>
{!isLoading && suggestions
? suggestions.recent_searches.map((entry, index) => (
<SearchHistoryChip
key={entry.search_id || `${entry.search_query}-${index}`}
content={entry.search_query || "Untitled search"}
onClick={() => {
setSearchQuery(entry.search_query || "");
setSearchId(entry.search_id || undefined);
}}
/>
))
: Array(3)
.fill(0)
.map((_, index) => (
<SearchHistoryChip.Skeleton
key={`recent-search-skeleton-${index}`}
/>
))}
</HorizontalScroll>
</div>
)}
{/* Integrations */}
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">

View File

@@ -141,7 +141,6 @@ export function ChatCredentialsSetup({
onSelectCredentials={(credMeta) =>
handleCredentialSelect(cred.provider, credMeta)
}
hideIfSingleCredentialAvailable={false}
/>
</div>
);

View File

@@ -10,11 +10,13 @@ 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 { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
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";
@@ -22,11 +24,13 @@ import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
const {
agent,
hasAnyItems,
ready,
error,
agentId,
agent,
ready,
activeTemplate,
isTemplateLoading,
error,
hasAnyItems,
activeItem,
sidebarLoading,
activeTab,
@@ -34,6 +38,9 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
} = useNewAgentLibraryView();
if (error) {
@@ -63,14 +70,19 @@ export function NewAgentLibraryView() {
/>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks agent={agent} />
<EmptyTasks
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</div>
</div>
);
}
return (
<div className="ml-4 grid h-full grid-cols-1 gap-0 pt-3 md:gap-4 lg:grid-cols-[25%_70%]">
<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">
<div
className={cn(
@@ -80,15 +92,21 @@ export function NewAgentLibraryView() {
>
<RunAgentModal
triggerSlot={
<Button variant="primary" size="large" className="w-full">
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={(execution) => handleSelectRun(execution.id, "runs")}
onScheduleCreated={(schedule) =>
handleSelectRun(schedule.id, "scheduled")
}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
</div>
@@ -112,12 +130,17 @@ export function NewAgentLibraryView() {
) : activeTab === "templates" ? (
<SelectedTemplateView
agent={agent}
presetID={activeItem}
onCreateRun={(runId) => handleSelectRun(runId, "runs")}
onCreateSchedule={(scheduleId) =>
handleSelectRun(scheduleId, "scheduled")
}
onDelete={handleClearSelectedRun}
templateId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onRunCreated={(execution) => handleSelectRun(execution.id, "runs")}
onSwitchToRunsTab={() => setActiveTab("runs")}
/>
) : activeTab === "triggers" ? (
<SelectedTriggerView
agent={agent}
triggerId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onSwitchToRunsTab={() => setActiveTab("runs")}
/>
) : (
<SelectedRunView
@@ -137,9 +160,18 @@ export function NewAgentLibraryView() {
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<EmptyTemplates />
</SelectedViewLayout>
) : activeTab === "triggers" ? (
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<EmptyTriggers />
</SelectedViewLayout>
) : (
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<EmptyTasks agent={agent} />
<EmptyTasks
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</SelectedViewLayout>
)}
</div>

View File

@@ -1,13 +1,14 @@
"use client";
import React from "react";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import type {
BlockIOSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import {
getAgentCredentialsFields,
getAgentInputFields,
getCredentialTypeDisplayName,
renderValue,
} from "./helpers";
@@ -22,13 +23,21 @@ export function AgentInputsReadOnly({
inputs,
credentialInputs,
}: Props) {
const fields = getAgentInputFields(agent);
const credentialFields = getAgentCredentialsFields(agent);
const inputEntries = Object.entries(fields);
const credentialEntries = Object.entries(credentialFields);
const inputFields = getAgentInputFields(agent);
const credentialFieldEntries = Object.entries(
getAgentCredentialsFields(agent),
);
const hasInputs = inputs && inputEntries.length > 0;
const hasCredentials = credentialInputs && credentialEntries.length > 0;
// Take actual input entries as leading; augment with schema from input fields.
// TODO: ensure consistent ordering.
const inputEntries =
inputs &&
Object.entries(inputs).map<[string, [BlockIOSubSchema | undefined, any]]>(
([k, v]) => [k, [inputFields[k], v]],
);
const hasInputs = inputEntries && inputEntries.length > 0;
const hasCredentials = credentialInputs && credentialFieldEntries.length > 0;
if (!hasInputs && !hasCredentials) {
return <div className="text-neutral-600">No input for this run.</div>;
@@ -39,11 +48,13 @@ export function AgentInputsReadOnly({
{/* Regular inputs */}
{hasInputs && (
<div className="flex flex-col gap-4">
{inputEntries.map(([key, sub]) => (
{inputEntries.map(([key, [schema, value]]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{sub?.title || key}</label>
<label className="text-sm font-medium">
{schema?.title || key}
</label>
<p className="whitespace-pre-wrap break-words text-sm text-neutral-700">
{renderValue((inputs as Record<string, any>)[key])}
{renderValue(value)}
</p>
</div>
))}
@@ -54,32 +65,18 @@ export function AgentInputsReadOnly({
{hasCredentials && (
<div className="flex flex-col gap-6">
{hasInputs && <div className="border-t border-neutral-200 pt-4" />}
{credentialEntries.map(([key, _sub]) => {
{credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
return (
<div key={key} className="flex flex-col gap-4">
<h3 className="text-lg font-medium text-neutral-900">
{toDisplayName(credential.provider)} credentials
</h3>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">Name</span>
<span className="text-neutral-600">
{getCredentialTypeDisplayName(credential.type)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-900">
{credential.title || "Untitled"}
</span>
<span className="font-mono text-neutral-400">
{"*".repeat(25)}
</span>
</div>
</div>
</div>
<CredentialsInput
key={key}
schema={{ ...inputSubSchema, discriminator: undefined } as any}
selectedCredentials={credential}
onSelectCredentials={() => {}}
readOnly={true}
/>
);
})}
</div>

View File

@@ -13,11 +13,10 @@ export function getCredentialTypeDisplayName(type: string): string {
}
export function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
const schema = agent.trigger_setup_info
? agent.trigger_setup_info.config_schema
: (agent.input_schema as unknown as {
properties?: Record<string, any>;
} | null);
const schema = (agent.trigger_setup_info?.config_schema ??
agent.input_schema) as unknown as {
properties?: Record<string, any>;
} | null;
if (!schema || !schema.properties) return {};
const properties = schema.properties as Record<string, any>;
const visibleEntries = Object.entries(properties).filter(

View File

@@ -1,189 +1,59 @@
import {
IconKey,
IconKeyPlus,
IconUserPlus,
} from "@/components/__legacy__/ui/icons";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { getHostFromUrl } from "@/lib/utils/url";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import { FC, useEffect, useMemo, useState } from "react";
import {
FaDiscord,
FaGithub,
FaGoogle,
FaHubspot,
FaKey,
FaMedium,
FaTwitter,
} from "react-icons/fa";
import { APIKeyCredentialsModal } from "./APIKeyCredentialsModal/APIKeyCredentialsModal";
import { HostScopedCredentialsModal } from "./HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./PasswordCredentialsModal/PasswordCredentialsModal";
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 { useCredentialsInputs } from "./useCredentialsInputs";
const fallbackIcon = FaKey;
type UseCredentialsInputsReturn = ReturnType<typeof useCredentialsInputs>;
// --8<-- [start:ProviderIconsEmbed]
// Provider icons mapping - uses fallback for unknown providers
export const providerIcons: Partial<
Record<string, React.FC<{ className?: string }>>
> = {
aiml_api: fallbackIcon,
anthropic: fallbackIcon,
apollo: fallbackIcon,
e2b: fallbackIcon,
github: FaGithub,
google: FaGoogle,
groq: fallbackIcon,
http: fallbackIcon,
notion: NotionLogoIcon,
nvidia: fallbackIcon,
discord: FaDiscord,
d_id: fallbackIcon,
google_maps: FaGoogle,
jina: fallbackIcon,
ideogram: fallbackIcon,
linear: fallbackIcon,
medium: FaMedium,
mem0: fallbackIcon,
ollama: fallbackIcon,
openai: fallbackIcon,
openweathermap: fallbackIcon,
open_router: fallbackIcon,
llama_api: fallbackIcon,
pinecone: fallbackIcon,
enrichlayer: fallbackIcon,
slant3d: fallbackIcon,
screenshotone: fallbackIcon,
smtp: fallbackIcon,
replicate: fallbackIcon,
reddit: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,
twitter: FaTwitter,
unreal_speech: fallbackIcon,
exa: fallbackIcon,
hubspot: FaHubspot,
smartlead: fallbackIcon,
todoist: fallbackIcon,
zerobounce: fallbackIcon,
};
// --8<-- [end:ProviderIconsEmbed]
function isLoaded(
data: UseCredentialsInputsReturn,
): data is Extract<UseCredentialsInputsReturn, { isLoading: false }> {
return data.isLoading === false;
}
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);
export const CredentialsInput: FC<{
type Props = {
schema: BlockIOCredentialsSubSchema;
className?: string;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
hideIfSingleCredentialAvailable?: boolean;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
onLoaded?: (loaded: boolean) => void;
}> = ({
readOnly?: boolean;
};
export function CredentialsInput({
schema,
className,
selectedCredentials,
onSelectCredentials,
siblingInputs,
hideIfSingleCredentialAvailable = true,
onLoaded,
}) => {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
isUserPasswordCredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
] = useState(false);
const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] =
useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
readOnly = false,
}: Props) {
const hookData = useCredentialsInputs({
schema,
selectedCredentials,
onSelectCredentials,
siblingInputs,
onLoaded,
readOnly,
});
const api = useBackendAPI();
const credentials = useCredentials(schema, siblingInputs);
// Report loaded state to parent
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
// Deselect credentials if they do not exist (e.g. provider was changed)
useEffect(() => {
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
) {
onSelectCredentials(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials]);
const { hasRelevantCredentials, singleCredential } = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
hasRelevantCredentials: false,
singleCredential: null,
};
}
// Simple logic: if we have any saved credentials, we have relevant credentials
const hasRelevant = credentials.savedCredentials.length > 0;
// Auto-select single credential if only one exists
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
hasRelevantCredentials: hasRelevant,
singleCredential: single,
};
}, [credentials]);
// If only 1 credential is available, auto-select it and hide this input
useEffect(() => {
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials]);
if (
!credentials ||
credentials.isLoading ||
(singleCredential && hideIfSingleCredentialAvailable)
) {
if (!isLoaded(hookData)) {
return null;
}
@@ -194,309 +64,158 @@ export const CredentialsInput: FC<{
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
savedCredentials,
oAuthCallback,
} = credentials;
credentialsToShow,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
);
setOAuth2FlowInProgress(true);
const popup = window.open(login_url, "_blank", "popup=true");
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
if (!popup) {
throw new Error(
"Failed to open popup window. Please allow popups for this site.",
);
}
const controller = new AbortController();
setOAuthPopupController(controller);
controller.signal.onabort = () => {
console.debug("OAuth flow aborted");
setOAuth2FlowInProgress(false);
popup.close();
};
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
console.debug("Message received:", e.data);
if (
typeof e.data != "object" ||
!("message_type" in e.data) ||
e.data.message_type !== "oauth_popup_result"
) {
console.debug("Ignoring irrelevant message");
return;
}
if (!e.data.success) {
console.error("OAuth flow failed:", e.data.message);
setOAuthError(`OAuth flow failed: ${e.data.message}`);
setOAuth2FlowInProgress(false);
return;
}
if (e.data.state !== state_token) {
console.error("Invalid state token received");
setOAuthError("Invalid state token received");
setOAuth2FlowInProgress(false);
return;
}
try {
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
id: credentials.id,
type: "oauth2",
title: credentials.title,
provider,
});
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
// type of error is unkown so we need to use String(error)
`Error in OAuth callback: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
console.debug("Finalizing OAuth flow");
setOAuth2FlowInProgress(false);
controller.abort("success");
}
};
console.debug("Adding message event listener");
window.addEventListener("message", handleMessage, {
signal: controller.signal,
});
setTimeout(
() => {
console.debug("OAuth flow timed out");
controller.abort("timeout");
setOAuth2FlowInProgress(false);
setOAuthError("OAuth flow timed out");
},
5 * 60 * 1000,
);
}
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const modals = (
<>
{supportsApiKey && (
<APIKeyCredentialsModal
schema={schema}
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
{supportsOAuth2 && (
<OAuthFlowWaitingModal
open={isOAuth2FlowInProgress}
onClose={() => oAuthPopupController?.abort("canceled")}
providerName={providerName}
/>
)}
{supportsUserPassword && (
<PasswordCredentialsModal
schema={schema}
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
{supportsHostScoped && (
<HostScopedCredentialsModal
schema={schema}
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
</>
);
const fieldHeader = (
<div className="mb-2 flex gap-1">
<span className="text-m green text-gray-900">
{providerName} Credentials
</span>
<InformationTooltip description={schema.description} />
</div>
);
// Show credentials creation UI when no relevant credentials exist
if (!hasRelevantCredentials) {
return (
<div className="mb-4">
{fieldHeader}
<div className={cn("flex flex-row space-x-2", className)}>
{supportsOAuth2 && (
<Button onClick={handleOAuthLogin} size="small">
<ProviderIcon className="mr-2 h-4 w-4" />
{"Sign in with " + providerName}
</Button>
)}
{supportsApiKey && (
<Button
onClick={() => setAPICredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
Enter API key
</Button>
)}
{supportsUserPassword && (
<Button
onClick={() => setUserPasswordCredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
Enter username and password
</Button>
)}
{supportsHostScoped && credentials.discriminatorValue && (
<Button
onClick={() => setHostScopedCredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
{`Enter sensitive headers for ${getHostFromUrl(credentials.discriminatorValue)}`}
</Button>
)}
</div>
{modals}
{oAuthError && (
<div className="mt-2 text-red-500">Error: {oAuthError}</div>
return (
<div className={cn("mb-6", className)}>
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
);
}
function handleValueChange(newValue: string) {
if (newValue === "sign-in") {
// Trigger OAuth2 sign in flow
handleOAuthLogin();
} else if (newValue === "add-api-key") {
// Open API key dialog
setAPICredentialsModalOpen(true);
} else if (newValue === "add-user-password") {
// Open user password dialog
setUserPasswordCredentialsModalOpen(true);
} else if (newValue === "add-host-scoped") {
// Open host-scoped credentials dialog
setHostScopedCredentialsModalOpen(true);
} else {
const selectedCreds = savedCredentials.find((c) => c.id == newValue)!;
{hasCredentialsToShow ? (
<>
{credentialsToShow.length > 1 && !readOnly ? (
<CredentialsSelect
credentials={credentialsToShow}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredentials}
onSelectCredential={handleCredentialSelect}
readOnly={readOnly}
/>
) : (
<div className="mb-4 space-y-2">
{credentialsToShow.map((credential) => {
return (
<CredentialRow
key={credential.id}
credential={credential}
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
})}
</div>
)}
{!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
>
{actionButtonText}
</Button>
)}
</>
) : (
!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
>
{actionButtonText}
</Button>
)
)}
onSelectCredentials({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
// title: customTitle, // TODO: add input for title
});
}
}
{!readOnly && (
<>
{supportsApiKey ? (
<APIKeyCredentialsModal
schema={schema}
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsOAuth2 ? (
<OAuthFlowWaitingModal
open={isOAuth2FlowInProgress}
onClose={() => oAuthPopupController?.abort("canceled")}
providerName={providerName}
/>
) : null}
{supportsUserPassword ? (
<PasswordCredentialsModal
schema={schema}
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsHostScoped ? (
<HostScopedCredentialsModal
schema={schema}
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
// Saved credentials exist
return (
<div>
{fieldHeader}
{oAuthError ? (
<Text variant="body" className="mt-2 text-red-500">
Error: {oAuthError}
</Text>
) : null}
<Select value={selectedCredentials?.id} onValueChange={handleValueChange}>
<SelectTrigger>
<SelectValue placeholder={schema.placeholder} />
</SelectTrigger>
<SelectContent className="nodrag">
{savedCredentials
.filter((c) => c.type == "oauth2")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
{credentials.title ||
credentials.username ||
`Your ${providerName} account`}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "api_key")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "user_password")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconUserPlus className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "host_scoped")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
<SelectSeparator />
{supportsOAuth2 && (
<SelectItem value="sign-in">
<IconUserPlus className="mr-1.5 inline" />
Sign in with {providerName}
</SelectItem>
)}
{supportsApiKey && (
<SelectItem value="add-api-key">
<IconKeyPlus className="mr-1.5 inline" />
Add new API key
</SelectItem>
)}
{supportsUserPassword && (
<SelectItem value="add-user-password">
<IconUserPlus className="mr-1.5 inline" />
Add new user password
</SelectItem>
)}
{supportsHostScoped && (
<SelectItem value="add-host-scoped">
<IconKey className="mr-1.5 inline" />
Add host-scoped headers
</SelectItem>
)}
</SelectContent>
</Select>
{modals}
{oAuthError && (
<div className="mt-2 text-red-500">Error: {oAuthError}</div>
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</>
)}
</div>
);
};
}

View File

@@ -0,0 +1,102 @@
import { IconKey } from "@/components/__legacy__/ui/icons";
import { Text } from "@/components/atoms/Text/Text";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { cn } from "@/lib/utils";
import { CaretDown, DotsThreeVertical } from "@phosphor-icons/react";
import {
fallbackIcon,
getCredentialDisplayName,
MASKED_KEY_LENGTH,
providerIcons,
} from "../../helpers";
type CredentialRowProps = {
credential: {
id: string;
title?: string;
username?: string;
type: string;
provider: string;
};
provider: string;
displayName: string;
onSelect: () => void;
onDelete: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
};
export function CredentialRow({
credential,
provider,
displayName,
onSelect,
onDelete,
readOnly = false,
showCaret = false,
asSelectTrigger = false,
}: CredentialRowProps) {
const ProviderIcon = providerIcons[provider] || fallbackIcon;
return (
<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" : "",
)}
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
style={
readOnly || showCaret || asSelectTrigger
? { cursor: showCaret || asSelectTrigger ? "pointer" : "default" }
: undefined
}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900">
<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">
<Text variant="body" className="tracking-tight">
{getCredentialDisplayName(credential, displayName)}
</Text>
<Text
variant="large"
className="relative top-1 font-mono tracking-tight"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
</div>
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useEffect } from "react";
import { getCredentialDisplayName } from "../../helpers";
import { CredentialRow } from "../CredentialRow/CredentialRow";
interface Props {
credentials: Array<{
id: string;
title?: string;
username?: string;
type: string;
provider: string;
}>;
provider: string;
displayName: string;
selectedCredentials?: CredentialsMetaInput;
onSelectCredential: (credentialId: string) => void;
readOnly?: boolean;
}
export function CredentialsSelect({
credentials,
provider,
displayName,
selectedCredentials,
onSelectCredential,
readOnly = false,
}: Props) {
// Auto-select first credential if none is selected
useEffect(() => {
if (!selectedCredentials && credentials.length > 0) {
onSelectCredential(credentials[0].id);
}
}, [selectedCredentials, credentials, onSelectCredential]);
return (
<div className="mb-4 w-full">
<Select
value={selectedCredentials?.id || ""}
onValueChange={(value) => onSelectCredential(value)}
>
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
{selectedCredentials ? (
<SelectValue key={selectedCredentials.id} asChild>
<CredentialRow
credential={{
id: selectedCredentials.id,
title: selectedCredentials.title || undefined,
type: selectedCredentials.type,
provider: selectedCredentials.provider,
}}
provider={provider}
displayName={displayName}
onSelect={() => {}}
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
/>
</SelectValue>
) : (
<SelectValue key="placeholder" placeholder="Select credential" />
)}
</SelectTrigger>
<SelectContent>
{credentials.map((credential) => (
<SelectItem key={credential.id} value={credential.id}>
<div className="flex items-center gap-2">
<Text variant="body" className="tracking-tight">
{getCredentialDisplayName(credential, displayName)}
</Text>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
interface Props {
credentialToDelete: { id: string; title: string } | null;
isDeleting: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteConfirmationModal({
credentialToDelete,
isDeleting,
onClose,
onConfirm,
}: Props) {
return (
<Dialog
controlled={{
isOpen: credentialToDelete !== null,
set: (open) => {
if (!open) onClose();
},
}}
title="Delete credential"
styling={{ maxWidth: "32rem" }}
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete &quot;{credentialToDelete?.title}
&quot;? This action cannot be undone.
</Text>
<Dialog.Footer>
<Button variant="secondary" onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
loading={isDeleting}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,15 +1,15 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import useCredentials from "@/hooks/useCredentials";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
type Props = {
schema: BlockIOCredentialsSubSchema;
@@ -85,7 +85,7 @@ export function PasswordCredentialsModal({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 pt-4"
className="w-[98%] space-y-2 pt-4"
>
<FormField
control={form.control}
@@ -96,7 +96,6 @@ export function PasswordCredentialsModal({
label="Username"
type="text"
placeholder="Enter username..."
size="small"
{...field}
/>
)}
@@ -110,7 +109,6 @@ export function PasswordCredentialsModal({
label="Password"
type="password"
placeholder="Enter password..."
size="small"
{...field}
/>
)}
@@ -124,12 +122,12 @@ export function PasswordCredentialsModal({
label="Name"
type="text"
placeholder="Enter a name for this user login..."
size="small"
className="mb-8"
{...field}
/>
)}
/>
<Button type="submit" size="small" className="min-w-68">
<Button type="submit" className="w-full">
Save & use this user login
</Button>
</form>

View File

@@ -0,0 +1,102 @@
import { KeyIcon } from "@phosphor-icons/react";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import {
FaDiscord,
FaGithub,
FaGoogle,
FaHubspot,
FaMedium,
FaTwitter,
} from "react-icons/fa";
export const fallbackIcon = KeyIcon;
export const providerIcons: Partial<
Record<string, React.FC<{ className?: string }>>
> = {
aiml_api: fallbackIcon,
anthropic: fallbackIcon,
apollo: fallbackIcon,
e2b: fallbackIcon,
github: FaGithub,
google: FaGoogle,
groq: fallbackIcon,
http: fallbackIcon,
notion: NotionLogoIcon,
nvidia: fallbackIcon,
discord: FaDiscord,
d_id: fallbackIcon,
google_maps: FaGoogle,
jina: fallbackIcon,
ideogram: fallbackIcon,
linear: fallbackIcon,
medium: FaMedium,
mem0: fallbackIcon,
ollama: fallbackIcon,
openai: fallbackIcon,
openweathermap: fallbackIcon,
open_router: fallbackIcon,
llama_api: fallbackIcon,
pinecone: fallbackIcon,
enrichlayer: fallbackIcon,
slant3d: fallbackIcon,
screenshotone: fallbackIcon,
smtp: fallbackIcon,
replicate: fallbackIcon,
reddit: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,
twitter: FaTwitter,
unreal_speech: fallbackIcon,
exa: fallbackIcon,
hubspot: FaHubspot,
smartlead: fallbackIcon,
todoist: fallbackIcon,
zerobounce: fallbackIcon,
};
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);
export function getActionButtonText(
supportsOAuth2: boolean,
supportsApiKey: boolean,
supportsUserPassword: boolean,
supportsHostScoped: boolean,
hasExistingCredentials: boolean,
): string {
if (hasExistingCredentials) {
if (supportsOAuth2) return "Connect another account";
if (supportsApiKey) return "Use a new API key";
if (supportsUserPassword) return "Add a new username and password";
if (supportsHostScoped) return "Add new headers";
return "Add new credentials";
} else {
if (supportsOAuth2) return "Add account";
if (supportsApiKey) return "Add API key";
if (supportsUserPassword) return "Add username and password";
if (supportsHostScoped) return "Add headers";
return "Add credentials";
}
}
export function getCredentialDisplayName(
credential: { title?: string; username?: string },
displayName: string,
): string {
return (
credential.title || credential.username || `Your ${displayName} account`
);
}
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;

View File

@@ -0,0 +1,318 @@
import { useDeleteV1DeleteCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useQueryClient } from "@tanstack/react-query";
import { useContext, useEffect, useMemo, useState } from "react";
import {
getActionButtonText,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
type Args = {
schema: BlockIOCredentialsSubSchema;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
};
export function useCredentialsInputs({
schema,
selectedCredentials,
onSelectCredentials,
siblingInputs,
onLoaded,
readOnly = false,
}: Args) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
isUserPasswordCredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
] = useState(false);
const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] =
useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
const [credentialToDelete, setCredentialToDelete] = useState<{
id: string;
title: string;
} | null>(null);
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const allProviders = useContext(CredentialsProvidersContext);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["/api/integrations/credentials"],
});
queryClient.invalidateQueries({
queryKey: [`/api/integrations/${credentials?.provider}/credentials`],
});
setCredentialToDelete(null);
if (selectedCredentials?.id === credentialToDelete?.id) {
onSelectCredentials(undefined);
}
},
},
});
const rawProvider = credentials
? allProviders?.[credentials.provider as keyof typeof allProviders]
: null;
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
) {
onSelectCredentials(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials, readOnly]);
const { singleCredential } = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
singleCredential: null,
};
}
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
singleCredential: single,
};
}, [credentials]);
useEffect(() => {
if (readOnly) return;
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials, readOnly]);
if (
!credentials ||
credentials.isLoading ||
!("savedCredentials" in credentials)
) {
return {
isLoading: true,
};
}
const {
provider,
providerName,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
savedCredentials,
oAuthCallback,
} = credentials;
const allSavedCredentials = rawProvider?.savedCredentials || savedCredentials;
const credentialsToShow = (() => {
const creds = [...allSavedCredentials];
if (
!readOnly &&
selectedCredentials &&
!creds.some((c) => c.id === selectedCredentials.id)
) {
creds.push({
id: selectedCredentials.id,
type: selectedCredentials.type,
title: selectedCredentials.title || "Selected credential",
provider: provider,
} as any);
}
return creds;
})();
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
);
setOAuth2FlowInProgress(true);
const popup = window.open(login_url, "_blank", "popup=true");
if (!popup) {
throw new Error(
"Failed to open popup window. Please allow popups for this site.",
);
}
const controller = new AbortController();
setOAuthPopupController(controller);
controller.signal.onabort = () => {
console.debug("OAuth flow aborted");
setOAuth2FlowInProgress(false);
popup.close();
};
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
console.debug("Message received:", e.data);
if (
typeof e.data != "object" ||
!("message_type" in e.data) ||
e.data.message_type !== "oauth_popup_result"
) {
console.debug("Ignoring irrelevant message");
return;
}
if (!e.data.success) {
console.error("OAuth flow failed:", e.data.message);
setOAuthError(`OAuth flow failed: ${e.data.message}`);
setOAuth2FlowInProgress(false);
return;
}
if (e.data.state !== state_token) {
console.error("Invalid state token received");
setOAuthError("Invalid state token received");
setOAuth2FlowInProgress(false);
return;
}
try {
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
id: credentials.id,
type: "oauth2",
title: credentials.title,
provider,
});
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
`Error in OAuth callback: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
console.debug("Finalizing OAuth flow");
setOAuth2FlowInProgress(false);
controller.abort("success");
}
};
console.debug("Adding message event listener");
window.addEventListener("message", handleMessage, {
signal: controller.signal,
});
setTimeout(() => {
console.debug("OAuth flow timed out");
controller.abort("timeout");
setOAuth2FlowInProgress(false);
setOAuthError("OAuth flow timed out");
}, OAUTH_TIMEOUT_MS);
}
function handleActionButtonClick() {
if (supportsOAuth2) {
handleOAuthLogin();
} else if (supportsApiKey) {
setAPICredentialsModalOpen(true);
} else if (supportsUserPassword) {
setUserPasswordCredentialsModalOpen(true);
} else if (supportsHostScoped) {
setHostScopedCredentialsModalOpen(true);
}
}
function handleCredentialSelect(credentialId: string) {
const selectedCreds = credentialsToShow.find((c) => c.id === credentialId);
if (selectedCreds) {
onSelectCredentials({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
title: (selectedCreds as any).title,
});
}
}
function handleDeleteCredential(credential: { id: string; title: string }) {
setCredentialToDelete(credential);
}
function handleDeleteConfirm() {
if (credentialToDelete && credentials) {
deleteCredentialsMutation.mutate({
provider: credentials.provider,
credId: credentialToDelete.id,
});
}
}
return {
isLoading: false as const,
provider,
providerName,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow,
selectedCredentials,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText: getActionButtonText(
supportsOAuth2,
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
credentialsToShow.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
handleOAuthLogin,
onSelectCredentials,
schema,
siblingInputs,
};
}

View File

@@ -5,14 +5,15 @@ import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutio
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { AlarmIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { AgentCostSection } from "./components/AgentCostSection/AgentCostSection";
import { AgentDetails } from "./components/AgentDetails/AgentDetails";
import { AgentSectionHeader } from "./components/AgentSectionHeader/AgentSectionHeader";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
import { RunActions } from "./components/RunActions/RunActions";
@@ -24,15 +25,9 @@ interface Props {
agent: LibraryAgent;
initialInputValues?: Record<string, any>;
initialInputCredentials?: Record<string, any>;
initialPresetName?: string;
initialPresetDescription?: string;
onRunCreated?: (execution: GraphExecutionMeta) => void;
onTriggerSetup?: (preset: LibraryAgentPreset) => void;
onScheduleCreated?: (schedule: GraphExecutionJobInfo) => void;
editMode?: {
preset: LibraryAgentPreset;
onSaved?: (updatedPreset: LibraryAgentPreset) => void;
};
}
export function RunAgentModal({
@@ -40,12 +35,9 @@ export function RunAgentModal({
agent,
initialInputValues,
initialInputCredentials,
initialPresetName,
initialPresetDescription,
onRunCreated,
onTriggerSetup,
onScheduleCreated,
editMode,
}: Props) {
const {
// UI state
@@ -69,11 +61,6 @@ export function RunAgentModal({
setPresetName,
setPresetDescription,
// Edit mode
hasChanges,
isUpdatingPreset,
handleSave,
// Validation/readiness
allRequiredInputsAreSet,
@@ -90,12 +77,8 @@ export function RunAgentModal({
} = useAgentRunModal(agent, {
onRun: onRunCreated,
onSetupTrigger: onTriggerSetup,
onScheduleCreated,
initialInputValues,
initialInputCredentials,
initialPresetName,
initialPresetDescription,
editMode,
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
@@ -104,6 +87,8 @@ export function RunAgentModal({
Object.keys(agentInputFields || {}).length > 0 ||
Object.keys(agentCredentialsInputFields || {}).length > 0;
const isTriggerRunType = defaultRunType.includes("trigger");
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -141,8 +126,6 @@ export function RunAgentModal({
onScheduleCreated?.(schedule);
}
const templateOrTrigger = agent.trigger_setup_info ? "Trigger" : "Template";
return (
<>
<Dialog
@@ -151,157 +134,90 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
<div className="flex h-full flex-col pb-4">
{/* Header */}
<div className="flex-shrink-0">
<ModalHeader agent={agent} />
<AgentCostSection flowId={agent.graph_id} />
</div>
{/* Header */}
<ModalHeader agent={agent} />
{/* Scrollable content */}
<div className="flex-1 pr-1" style={{ scrollbarGutter: "stable" }}>
{/* Template Info Section (Edit Mode Only) */}
{editMode && (
<div className="mt-10">
<AgentSectionHeader
title={`${templateOrTrigger} Information`}
/>
<div className="mb-10 mt-4 space-y-4">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">
{templateOrTrigger} Name
</label>
<Input
id="template_name"
label="Template Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter template name"
onChange={(e) => setPresetName(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">
{templateOrTrigger} Description
</label>
<Input
id="template_description"
label="Template Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter template description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</div>
</div>
</div>
)}
{/* Setup Section */}
<div className={editMode ? "mt-8" : "mt-10"}>
{hasAnySetupFields ? (
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
presetEditMode: Boolean(
editMode || agent.trigger_setup_info,
),
presetName,
setPresetName,
presetDescription,
setPresetDescription,
}}
>
<>
<AgentSectionHeader
title={
defaultRunType === "automatic-trigger"
? "Trigger Setup"
: "Agent Setup"
}
/>
<ModalRunSection />
</>
</RunAgentModalContextProvider>
) : null}
</div>
{/* Agent Details Section */}
<div className="mt-8">
<AgentSectionHeader title="Agent Details" />
<AgentDetails agent={agent} />
</div>
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
</div>
<Dialog.Footer
className="fixed bottom-1 left-0 z-10 w-full bg-white p-4"
style={{ boxShadow: "0px -8px 10px white" }}
>
) : null}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
{editMode ? (
<>
<Button
variant="secondary"
onClick={() => setIsOpen(false)}
disabled={isUpdatingPreset}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={
!hasChanges || isUpdatingPreset || !presetName.trim()
}
>
{isUpdatingPreset ? "Saving..." : "Save Changes"}
</Button>
</>
{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>
) : (
<>
{(defaultRunType == "manual" ||
defaultRunType == "schedule") && (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
<AlarmIcon size={16} />
Schedule Agent
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
)}
</div>
{(defaultRunType == "manual" || defaultRunType == "schedule") && (
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
<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}
/>
</Dialog.Footer>
</Dialog.Content>
</Dialog>

View File

@@ -1,31 +0,0 @@
import { Button } from "@/components/atoms/Button/Button";
interface Props {
flowId: string;
}
export function AgentCostSection({ flowId }: Props) {
return (
<div className="mt-6 flex items-center justify-between">
{/* TODO: enable once we have an API to show estimated cost for an agent run */}
{/* <div className="flex items-center gap-2">
<Text variant="body-medium">Cost</Text>
<Text variant="body">{cost}</Text>
</div> */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="small"
as="NextLink"
href={`/build?flowID=${flowId}`}
>
Open in builder
</Button>
{/* TODO: enable once we can easily link to the agent listing page from the library agent response */}
{/* <Button variant="outline" size="small">
View listing <ArrowSquareOutIcon size={16} />
</Button> */}
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { Badge } from "@/components/atoms/Badge/Badge";
import { formatDate } from "@/lib/utils/time";
interface Props {
agent: LibraryAgent;
}
export function AgentDetails({ agent }: Props) {
return (
<div className="mt-4 flex flex-col gap-5">
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Version
</Text>
<div className="flex items-center gap-2">
<Text variant="body" className="!text-zinc-700">
v{agent.graph_version}
</Text>
{agent.is_latest_version && (
<Badge variant="success" size="small">
Latest
</Badge>
)}
</div>
</div>
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Last Updated
</Text>
<Text variant="body" className="!text-zinc-700">
{formatDate(agent.updated_at)}
</Text>
</div>
{agent.has_external_trigger && (
<div>
<Text variant="body-medium" className="mb-1">
Trigger Type
</Text>
<Text variant="body" className="!text-neutral-700">
External Webhook
</Text>
</div>
)}
</div>
);
}

View File

@@ -1,15 +0,0 @@
import { Text } from "@/components/atoms/Text/Text";
interface Props {
title: string;
}
export function AgentSectionHeader({ title }: Props) {
return (
<div className="border-t border-zinc-400 px-0 pb-2 pt-1">
<Text variant="label" className="!text-zinc-700">
{title}
</Text>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { Badge } from "@/components/atoms/Badge/Badge";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Link } from "@/components/atoms/Link/Link";
import { Text } from "@/components/atoms/Text/Text";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
import { ClockIcon, InfoIcon } from "@phosphor-icons/react";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
interface ModalHeaderProps {
@@ -10,49 +10,56 @@ interface ModalHeaderProps {
}
export function ModalHeader({ agent }: ModalHeaderProps) {
const isUnknownCreator = agent.creator_name === "Unknown";
const creator = agent.marketplace_listing?.creator;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Badge variant="info">New Run</Badge>
</div>
<div className="flex flex-col gap-4">
<Badge variant="info" className="w-fit">
New Task
</Badge>
<div>
<Text variant="h3">{agent.name}</Text>
{!isUnknownCreator ? (
<Text variant="body-medium">by {agent.creator_name}</Text>
<Text variant="h2">{agent.name}</Text>
{creator ? (
<Link href={`/marketplace/creator/${creator.slug}`} isExternal>
by {creator.name}
</Link>
) : null}
<ShowMoreText
previewLimit={80}
variant="small"
className="mt-4 !text-zinc-700"
>
{agent.description}
</ShowMoreText>
{/* Schedule recommendation tip */}
{agent.recommended_schedule_cron && !agent.has_external_trigger && (
<div className="mt-4 flex items-center gap-2">
<ClockIcon className="h-4 w-4 text-gray-500" />
<p className="text-sm text-gray-600">
<strong>Tip:</strong> For best results, run this agent{" "}
{agent.description ? (
<ShowMoreText
previewLimit={400}
variant="small"
className="mt-4 !text-zinc-700"
>
{agent.description}
</ShowMoreText>
) : null}
{agent.recommended_schedule_cron && !agent.has_external_trigger ? (
<div className="flex flex-col gap-4 rounded-medium border border-blue-100 bg-blue-50 p-4">
<Text variant="lead-semibold" className="text-blue-600">
Tip
</Text>
<Text variant="body">
For best results, run this agent{" "}
{humanizeCronExpression(
agent.recommended_schedule_cron,
).toLowerCase()}
</p>
</Text>
</div>
)}
) : null}
{/* Setup Instructions */}
{agent.instructions && (
<div className="mt-4 flex items-start gap-2">
<InfoIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-500" />
<div className="text-sm text-gray-600">
<strong>Setup Instructions:</strong>{" "}
<span className="whitespace-pre-wrap">{agent.instructions}</span>
</div>
{agent.instructions ? (
<div className="flex flex-col gap-4 rounded-medium border border-purple-100 bg-[#F1EBFE/5] p-4">
<Text variant="lead-semibold" className="text-purple-600">
Instructions
</Text>
<div className="h-px w-full bg-purple-100" />
<Text variant="body">{agent.instructions}</Text>
</div>
)}
) : null}
</div>
</div>
);

View File

@@ -1,172 +1,125 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { InfoIcon } from "@phosphor-icons/react";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
import { getCredentialTypeDisplayName } from "./helpers";
export function ModalRunSection() {
const {
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue,
agentInputFields,
inputCredentials,
setInputCredentialsValue,
agentCredentialsInputFields,
presetEditMode,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
} = useRunAgentModalContext();
const inputFields = Object.entries(agentInputFields || {});
const credentialFields = Object.entries(agentCredentialsInputFields || {});
return (
<div className="mb-10 mt-4 flex flex-col gap-4">
{defaultRunType === "automatic-trigger" && <WebhookTriggerBanner />}
{/* Preset/Trigger fields */}
{presetEditMode && (
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Name
<InformationTooltip description="Name of the trigger you are setting up" />
</label>
<Input
id="trigger_name"
label="Trigger Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter trigger name"
onChange={(e) => setPresetName(e.target.value)}
/>
<div className="flex flex-col gap-4">
{defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger" ? (
<ModalSection
title="Task Trigger"
subtitle="Set up a trigger for the agent to run this task automatically"
>
<WebhookTriggerBanner />
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Name
<InformationTooltip description="Name of the trigger you are setting up" />
</label>
<Input
id="trigger_name"
label="Trigger Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter trigger name"
onChange={(e) => setPresetName(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Description
<InformationTooltip description="Description of the trigger you are setting up" />
</label>
<Input
id="trigger_description"
label="Trigger Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter trigger description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Description
<InformationTooltip description="Description of the trigger you are setting up" />
</label>
<Input
id="trigger_description"
label="Trigger Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter trigger description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</ModalSection>
) : null}
{inputFields.length > 0 ? (
<ModalSection
title="Task Inputs"
subtitle="Enter the information you want to provide to the agent for this task"
>
{/* Regular inputs */}
{inputFields.map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip description={inputSubSchema.description} />
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
))}
</ModalSection>
) : null}
{credentialFields.length > 0 ? (
<ModalSection
title="Task Credentials"
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
}
selectedCredentials={
(inputCredentials && inputCredentials[key]) ??
inputSubSchema.default
}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
/>
),
)}
</div>
</div>
)}
{/* Instructions */}
{agent.instructions && (
<div className="mb-4 flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3">
<InfoIcon className="mt-0.5 h-4 w-4 text-blue-600" />
<div>
<h4 className="text-sm font-medium text-blue-900">
How to use this agent
</h4>
<p className="mt-1 whitespace-pre-wrap text-sm text-blue-800">
{agent.instructions}
</p>
</div>
</div>
)}
{/* Credentials inputs */}
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={{ ...inputSubSchema, discriminator: undefined } as any}
selectedCredentials={
(inputCredentials && inputCredentials[key]) ??
inputSubSchema.default
}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
hideIfSingleCredentialAvailable={!agent.has_external_trigger}
/>
),
)}
{/* Regular inputs */}
{Object.entries(agentInputFields || {}).map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip description={inputSubSchema.description} />
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
))}
{/* Selected Credentials Preview */}
{Object.keys(inputCredentials).length > 0 && (
<div className="mt-2 flex flex-col gap-6">
{Object.entries(agentCredentialsInputFields || {}).map(
([key, _sub]) => {
const credential = inputCredentials[key];
if (!credential) return null;
return (
<div key={key} className="flex flex-col gap-4">
<Text variant="body-medium" as="h3">
{toDisplayName(credential.provider)} credentials
</Text>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<Text
variant="body"
as="span"
className="!text-neutral-600"
>
Name
</Text>
<Text
variant="body"
as="span"
className="!text-neutral-600"
>
{getCredentialTypeDisplayName(credential.type)}
</Text>
</div>
<div className="flex items-center justify-between text-sm">
<Text
variant="body"
as="span"
className="!text-neutral-900"
>
{credential.title || "Untitled"}
</Text>
<span className="font-mono text-neutral-400">
{"*".repeat(25)}
</span>
</div>
</div>
</div>
);
},
)}
</div>
)}
</ModalSection>
) : null}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Text } from "@/components/atoms/Text/Text";
interface Props {
title: string;
subtitle: string;
children: React.ReactNode;
}
export function ModalSection({ title, subtitle, children }: Props) {
return (
<div className="rounded-medium border border-zinc-200 p-6">
<div className="mb-4 flex flex-col gap-1 border-b border-zinc-100 pb-4">
<Text variant="lead-semibold">{title}</Text>
<Text variant="body" className="text-zinc-500">
{subtitle}
</Text>
</div>
{children}
</div>
);
}

View File

@@ -24,9 +24,10 @@ export function RunActions({
disabled={!isRunReady || isExecuting || isSettingUpTrigger}
loading={isExecuting || isSettingUpTrigger}
>
{defaultRunType === "automatic-trigger"
{defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger"
? "Set up Trigger"
: "Run Agent"}
: "Start Task"}
</Button>
</div>
);

View File

@@ -1,13 +1,6 @@
import { cn } from "@/lib/utils";
export function WebhookTriggerBanner({ className }: { className?: string }) {
export function WebhookTriggerBanner() {
return (
<div
className={cn(
"rounded-lg border border-blue-200 bg-blue-50 p-4",
className,
)}
>
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg

View File

@@ -4,9 +4,14 @@ import React, { createContext, useContext } from "react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { RunVariant } from "./useAgentRunModal";
export type RunAgentModalContextValue = {
export interface RunAgentModalContextValue {
agent: LibraryAgent;
defaultRunType: RunVariant;
// Preset / Trigger
presetName: string;
setPresetName: (value: string) => void;
presetDescription: string;
setPresetDescription: (value: string) => void;
// Inputs
inputValues: Record<string, any>;
setInputValue: (key: string, value: any) => void;
@@ -15,14 +20,7 @@ export type RunAgentModalContextValue = {
inputCredentials: Record<string, any>;
setInputCredentialsValue: (key: string, value: any | undefined) => void;
agentCredentialsInputFields: Record<string, any>;
// Trigger / Preset fields
presetEditMode: boolean; // determines whether to show the name and description fields
presetName: string;
setPresetName: (value: string) => void;
presetDescription: string;
setPresetDescription: (value: string) => void;
};
}
const RunAgentModalContext = createContext<RunAgentModalContextValue | null>(
null,

View File

@@ -0,0 +1,100 @@
import { ApiError } from "@/lib/autogpt-server-api/helpers";
import Link from "next/link";
import React from "react";
type ValidationErrorDetail = {
type: string;
message?: string;
node_errors?: Record<string, Record<string, string>>;
};
type AgentInfo = {
graph_id: string;
graph_version: number;
};
export function formatValidationError(
error: any,
agentInfo?: AgentInfo,
): string | React.ReactNode {
if (
!(error instanceof ApiError) ||
!error.isGraphValidationError() ||
!error.response?.detail
) {
return error.message || "An unexpected error occurred.";
}
const detail: ValidationErrorDetail = error.response.detail;
// Format validation errors nicely
if (detail.type === "validation_error" && detail.node_errors) {
const nodeErrors = detail.node_errors;
const errorItems: React.ReactNode[] = [];
// Collect all field errors
Object.entries(nodeErrors).forEach(([nodeId, fields]) => {
if (fields && typeof fields === "object") {
Object.entries(fields).forEach(([fieldName, fieldError]) => {
errorItems.push(
<div key={`${nodeId}-${fieldName}`} className="mt-1">
<span className="font-medium">{fieldName}:</span>{" "}
{String(fieldError)}
</div>,
);
});
}
});
if (errorItems.length > 0) {
return (
<div className="space-y-1">
<div className="font-medium text-white">
{detail.message || "Validation failed"}
</div>
<div className="mt-2 space-y-1 text-xs">{errorItems}</div>
{agentInfo && (
<div className="mt-3 text-xs">
Check the agent graph and try to run from there for further
details.{" "}
<Link
href={`/build?flowID=${agentInfo.graph_id}&flowVersion=${agentInfo.graph_version}`}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer underline hover:no-underline"
>
Open in builder
</Link>
</div>
)}
</div>
);
} else {
return detail.message || "Validation failed";
}
}
return detail.message || error.message || "An unexpected error occurred.";
}
export function showExecutionErrorToast(
toast: (options: {
title: string;
description: string | React.ReactNode;
variant: "destructive";
duration: number;
dismissable: boolean;
}) => void,
error: any,
agentInfo?: AgentInfo,
) {
const errorMessage = formatValidationError(error, agentInfo);
toast({
title: "Failed to execute agent",
description: errorMessage,
variant: "destructive",
duration: 10000, // 10 seconds - long enough to read and close
dismissable: true,
});
}

View File

@@ -1,529 +0,0 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useState, useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import {
usePostV1ExecuteGraphAgent,
getGetV1ListGraphExecutionsInfiniteQueryOptions,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
usePostV1CreateExecutionSchedule as useCreateSchedule,
getGetV1ListExecutionSchedulesForAGraphQueryKey,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import {
getGetV2ListPresetsQueryKey,
usePostV2SetupTrigger,
usePatchV2UpdateAnExistingPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import { analytics } from "@/services/analytics";
export type RunVariant =
| "manual"
| "schedule"
| "automatic-trigger"
| "manual-trigger";
interface UseAgentRunModalCallbacks {
onRun?: (execution: GraphExecutionMeta) => void;
onSetupTrigger?: (preset: LibraryAgentPreset) => void;
onScheduleCreated?: (schedule: GraphExecutionJobInfo) => void;
initialInputValues?: Record<string, any>;
initialInputCredentials?: Record<string, any>;
initialPresetName?: string;
initialPresetDescription?: string;
editMode?: {
preset: LibraryAgentPreset;
onSaved?: (updatedPreset: LibraryAgentPreset) => void;
};
}
export function useAgentRunModal(
agent: LibraryAgent,
callbacks?: UseAgentRunModalCallbacks,
) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showScheduleView, setShowScheduleView] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, any>>(
callbacks?.initialInputValues || callbacks?.editMode?.preset?.inputs || {},
);
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials ||
callbacks?.editMode?.preset?.credentials ||
{},
);
const [presetName, setPresetName] = useState<string>(
callbacks?.initialPresetName || callbacks?.editMode?.preset?.name || "",
);
const [presetDescription, setPresetDescription] = useState<string>(
callbacks?.initialPresetDescription ||
callbacks?.editMode?.preset?.description ||
"",
);
const defaultScheduleName = useMemo(() => `Run ${agent.name}`, [agent.name]);
const [scheduleName, setScheduleName] = useState(defaultScheduleName);
const [cronExpression, setCronExpression] = useState(
agent.recommended_schedule_cron || "0 9 * * 1",
);
// Get user timezone for scheduling
const { data: userTimezone } = useGetV1GetUserTimezone({
query: {
select: (res) => (res.status === 200 ? res.data.timezone : undefined),
},
});
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.has_external_trigger
? "automatic-trigger"
: "manual";
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Agent execution started",
});
callbacks?.onRun?.(response.data);
// Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
agent.graph_id,
).queryKey,
});
analytics.sendDatafastEvent("run_agent", {
name: agent.name,
id: agent.graph_id,
});
setIsOpen(false);
}
},
onError: (error: any) => {
const errorMessage = error.isGraphValidationError()
? error.response.detail.message
: error.message;
toast({
title: "❌ Failed to execute agent",
description: errorMessage || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const createScheduleMutation = useCreateSchedule({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Schedule created",
});
callbacks?.onScheduleCreated?.(response.data);
// Invalidate schedules list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
agent.graph_id,
),
});
analytics.sendDatafastEvent("schedule_agent", {
name: agent.name,
id: agent.graph_id,
cronExpression: cronExpression,
});
setIsOpen(false);
}
},
onError: (error: any) => {
toast({
title: "❌ Failed to create schedule",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const setupTriggerMutation = usePostV2SetupTrigger({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Trigger setup complete",
});
callbacks?.onSetupTrigger?.(response.data);
// Invalidate preset queries to show the newly created trigger
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: response.data.graph_id,
}),
});
analytics.sendDatafastEvent("setup_trigger", {
name: agent.name,
id: agent.graph_id,
});
setIsOpen(false);
}
},
onError: (error) => {
toast({
title: "❌ Failed to setup trigger",
description: String(error) || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Edit mode mutation for updating presets
const updatePresetMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Template updated successfully",
variant: "default",
});
setIsOpen(false);
callbacks?.editMode?.onSaved?.(response.data);
}
},
onError: (error: any) => {
toast({
title: "Failed to update template",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Input schema validation (use trigger schema for triggered agents)
const agentInputSchema = useMemo(() => {
if (agent.trigger_setup_info?.config_schema) {
return agent.trigger_setup_info.config_schema;
}
return agent.input_schema || { properties: {}, required: [] };
}, [agent.input_schema, agent.trigger_setup_info]);
const agentInputFields = useMemo(() => {
if (
!agentInputSchema ||
typeof agentInputSchema !== "object" ||
!("properties" in agentInputSchema) ||
!agentInputSchema.properties
) {
return {};
}
const properties = agentInputSchema.properties as Record<string, any>;
return Object.fromEntries(
Object.entries(properties).filter(
([_, subSchema]: [string, any]) => !subSchema.hidden,
),
);
}, [agentInputSchema]);
const agentCredentialsInputFields = useMemo(() => {
if (
!agent.credentials_input_schema ||
typeof agent.credentials_input_schema !== "object" ||
!("properties" in agent.credentials_input_schema) ||
!agent.credentials_input_schema.properties
) {
return {} as Record<string, any>;
}
return agent.credentials_input_schema.properties as Record<string, any>;
}, [agent.credentials_input_schema]);
// Validation logic
const [allRequiredInputsAreSetRaw, missingInputs] = useMemo(() => {
const nonEmptyInputs = new Set(
Object.keys(inputValues).filter((k) => !isEmpty(inputValues[k])),
);
const requiredInputs = new Set(
(agentInputSchema.required as string[]) || [],
);
const missing = [...requiredInputs].filter(
(input) => !nonEmptyInputs.has(input),
);
return [missing.length === 0, missing];
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
const availableCredentials = new Set(Object.keys(inputCredentials));
const allCredentials = new Set(
Object.keys(agentCredentialsInputFields || {}) ?? [],
);
const missing = [...allCredentials].filter(
(key) => !availableCredentials.has(key),
);
return [missing.length === 0, missing];
}, [agentCredentialsInputFields, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,
[agentCredentialsInputFields],
);
// Final readiness flag combining inputs + credentials when credentials are shown
const allRequiredInputsAreSet = useMemo(
() =>
allRequiredInputsAreSetRaw &&
(!credentialsRequired || allCredentialsAreSet),
[allRequiredInputsAreSetRaw, credentialsRequired, allCredentialsAreSet],
);
const notifyMissingRequirements = useCallback(
(needScheduleName: boolean = false) => {
const allMissingFields = (
needScheduleName && !scheduleName ? ["schedule_name"] : []
)
.concat(missingInputs)
.concat(
credentialsRequired && !allCredentialsAreSet
? missingCredentials.map((k) => `credentials:${k}`)
: [],
);
toast({
title: "⚠️ Missing required inputs",
description: `Please provide: ${allMissingFields.map((k) => `"${k}"`).join(", ")}`,
variant: "destructive",
});
},
[
missingInputs,
scheduleName,
toast,
credentialsRequired,
allCredentialsAreSet,
missingCredentials,
],
);
// Action handlers
const handleRun = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingRequirements();
return;
}
// FIXME: add support for "manual-trigger"
if (defaultRunType === "automatic-trigger") {
// Setup trigger
if (!presetName.trim()) {
toast({
title: "⚠️ Trigger name required",
description: "Please provide a name for your trigger.",
variant: "destructive",
});
return;
}
setupTriggerMutation.mutate({
data: {
name: presetName,
description: presetDescription || `Trigger for ${agent.name}`,
graph_id: agent.graph_id,
graph_version: agent.graph_version,
trigger_config: inputValues,
agent_credentials: inputCredentials,
},
});
} else {
// Manual execution
executeGraphMutation.mutate({
graphId: agent.graph_id,
graphVersion: agent.graph_version,
data: {
inputs: inputValues,
credentials_inputs: inputCredentials,
source: "library",
},
});
}
}, [
allRequiredInputsAreSet,
defaultRunType,
presetName,
inputValues,
inputCredentials,
agent,
presetDescription,
notifyMissingRequirements,
setupTriggerMutation,
executeGraphMutation,
toast,
]);
const handleSchedule = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingRequirements(true);
return;
}
if (!scheduleName.trim()) {
toast({
title: "⚠️ Schedule name required",
description: "Please provide a name for your schedule.",
variant: "destructive",
});
return;
}
createScheduleMutation.mutate({
graphId: agent.graph_id,
data: {
name: presetName || scheduleName,
cron: cronExpression,
inputs: inputValues,
graph_version: agent.graph_version,
credentials: inputCredentials,
timezone:
userTimezone && userTimezone !== "not-set" ? userTimezone : undefined,
},
});
}, [
allRequiredInputsAreSet,
scheduleName,
cronExpression,
inputValues,
inputCredentials,
agent,
notifyMissingRequirements,
createScheduleMutation,
toast,
userTimezone,
presetName,
]);
function handleShowSchedule() {
// Initialize with sensible defaults when entering schedule view
setScheduleName((prev) => prev || defaultScheduleName);
setCronExpression(
(prev) => prev || agent.recommended_schedule_cron || "0 9 * * 1",
);
setShowScheduleView(true);
}
function handleGoBack() {
setShowScheduleView(false);
// Reset schedule fields on exit
setScheduleName(defaultScheduleName);
setCronExpression(agent.recommended_schedule_cron || "0 9 * * 1");
}
function handleSetScheduleName(name: string) {
setScheduleName(name);
}
function handleSetCronExpression(expression: string) {
setCronExpression(expression);
}
// Edit mode save handler
const handleSave = useCallback(() => {
if (!callbacks?.editMode?.preset) return;
updatePresetMutation.mutate({
presetId: callbacks.editMode.preset.id,
data: {
name: presetName,
description: presetDescription,
inputs: inputValues,
credentials: inputCredentials,
},
});
}, [
callbacks?.editMode?.preset,
presetName,
presetDescription,
inputValues,
inputCredentials,
updatePresetMutation,
]);
// Check if there are changes in edit mode
const hasChanges = useMemo(() => {
if (!callbacks?.editMode?.preset) return false;
const preset = callbacks.editMode.preset;
return (
presetName !== preset.name ||
presetDescription !== preset.description ||
JSON.stringify(inputValues) !== JSON.stringify(preset.inputs || {}) ||
JSON.stringify(inputCredentials) !==
JSON.stringify(preset.credentials || {})
);
}, [
callbacks?.editMode?.preset,
presetName,
presetDescription,
inputValues,
inputCredentials,
]);
const hasInputFields = useMemo(() => {
return Object.keys(agentInputFields).length > 0;
}, [agentInputFields]);
return {
// UI state
isOpen,
setIsOpen,
showScheduleView,
// Run mode
defaultRunType: defaultRunType as RunVariant,
// Form: regular inputs
inputValues,
setInputValues,
// Form: credentials
inputCredentials,
setInputCredentials,
// Preset/trigger labels
presetName,
presetDescription,
setPresetName,
setPresetDescription,
// Scheduling
scheduleName,
cronExpression,
// Validation/readiness
allRequiredInputsAreSet,
missingInputs,
// Schemas for rendering
agentInputFields,
agentCredentialsInputFields,
hasInputFields,
// Async states
isExecuting: executeGraphMutation.isPending,
isCreatingSchedule: createScheduleMutation.isPending,
isSettingUpTrigger: setupTriggerMutation.isPending,
isUpdatingPreset: updatePresetMutation.isPending,
// Actions
handleRun,
handleSchedule,
handleShowSchedule,
handleGoBack,
handleSetScheduleName,
handleSetCronExpression,
handleSave,
hasChanges,
};
}

View File

@@ -0,0 +1,308 @@
import {
getGetV1ListGraphExecutionsQueryKey,
usePostV1ExecuteGraphAgent,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2ListPresetsQueryKey,
usePostV2SetupTrigger,
} from "@/app/api/__generated__/endpoints/presets/presets";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
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 { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useState } from "react";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
| "manual"
| "schedule"
| "automatic-trigger"
| "manual-trigger";
interface UseAgentRunModalCallbacks {
onRun?: (execution: GraphExecutionMeta) => void;
onSetupTrigger?: (preset: LibraryAgentPreset) => void;
initialInputValues?: Record<string, any>;
initialInputCredentials?: Record<string, any>;
}
export function useAgentRunModal(
agent: LibraryAgent,
callbacks?: UseAgentRunModalCallbacks,
) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, any>>(
callbacks?.initialInputValues || {},
);
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {},
);
const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>("");
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
? agent.trigger_setup_info.credentials_input_name
? "automatic-trigger"
: "manual-trigger"
: "manual";
// Update input values/credentials if template is selected/unselected
useEffect(() => {
setInputValues(callbacks?.initialInputValues || {});
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Agent execution started",
});
// Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsQueryKey(agent.graph_id),
});
callbacks?.onRun?.(response.data);
analytics.sendDatafastEvent("run_agent", {
name: agent.name,
id: agent.graph_id,
});
setIsOpen(false);
}
},
onError: (error: any) => {
showExecutionErrorToast(toast, error, {
graph_id: agent.graph_id,
graph_version: agent.graph_version,
});
},
},
});
const setupTriggerMutation = usePostV2SetupTrigger({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Trigger setup complete",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
callbacks?.onSetupTrigger?.(response.data);
setIsOpen(false);
}
},
onError: (error: any) => {
toast({
title: "❌ Failed to setup trigger",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Input schema validation (use trigger schema for triggered agents)
const agentInputSchema = useMemo(() => {
if (agent.trigger_setup_info?.config_schema) {
return agent.trigger_setup_info.config_schema;
}
return agent.input_schema || { properties: {}, required: [] };
}, [agent.input_schema, agent.trigger_setup_info]);
const agentInputFields = useMemo(() => {
if (
!agentInputSchema ||
typeof agentInputSchema !== "object" ||
!("properties" in agentInputSchema) ||
!agentInputSchema.properties
) {
return {};
}
const properties = agentInputSchema.properties as Record<string, any>;
return Object.fromEntries(
Object.entries(properties).filter(
([_, subSchema]: [string, any]) => !subSchema.hidden,
),
);
}, [agentInputSchema]);
const agentCredentialsInputFields = useMemo(() => {
if (
!agent.credentials_input_schema ||
typeof agent.credentials_input_schema !== "object" ||
!("properties" in agent.credentials_input_schema) ||
!agent.credentials_input_schema.properties
) {
return {} as Record<string, any>;
}
return agent.credentials_input_schema.properties as Record<string, any>;
}, [agent.credentials_input_schema]);
// Validation logic
const [allRequiredInputsAreSetRaw, missingInputs] = useMemo(() => {
const nonEmptyInputs = new Set(
Object.keys(inputValues).filter((k) => !isEmpty(inputValues[k])),
);
const requiredInputs = new Set(
(agentInputSchema.required as string[]) || [],
);
const missing = [...requiredInputs].filter(
(input) => !nonEmptyInputs.has(input),
);
return [missing.length === 0, missing];
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
const availableCredentials = new Set(Object.keys(inputCredentials));
const allCredentials = new Set(
Object.keys(agentCredentialsInputFields || {}) ?? [],
);
const missing = [...allCredentials].filter(
(key) => !availableCredentials.has(key),
);
return [missing.length === 0, missing];
}, [agentCredentialsInputFields, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,
[agentCredentialsInputFields],
);
// Final readiness flag combining inputs + credentials when credentials are shown
const allRequiredInputsAreSet = useMemo(
() =>
allRequiredInputsAreSetRaw &&
(!credentialsRequired || allCredentialsAreSet),
[allRequiredInputsAreSetRaw, credentialsRequired, allCredentialsAreSet],
);
const notifyMissingRequirements = useCallback(() => {
const allMissingFields = missingInputs.concat(
credentialsRequired && !allCredentialsAreSet
? missingCredentials.map((k) => `credentials:${k}`)
: [],
);
toast({
title: "⚠️ Missing required inputs",
description: `Please provide: ${allMissingFields.map((k) => `"${k}"`).join(", ")}`,
variant: "destructive",
});
}, [
missingInputs,
toast,
credentialsRequired,
allCredentialsAreSet,
missingCredentials,
]);
// Action handlers
const handleRun = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingRequirements();
return;
}
if (
defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger"
) {
// Setup trigger
if (!presetName.trim()) {
toast({
title: "⚠️ Trigger name required",
description: "Please provide a name for your trigger.",
variant: "destructive",
});
return;
}
setupTriggerMutation.mutate({
data: {
name: presetName,
description: presetDescription || `Trigger for ${agent.name}`,
graph_id: agent.graph_id,
graph_version: agent.graph_version,
trigger_config: inputValues,
agent_credentials: inputCredentials,
},
});
} else {
// Manual execution
executeGraphMutation.mutate({
graphId: agent.graph_id,
graphVersion: agent.graph_version,
data: {
inputs: inputValues,
credentials_inputs: inputCredentials,
source: "library",
},
});
}
}, [
allRequiredInputsAreSet,
defaultRunType,
inputValues,
inputCredentials,
agent,
presetName,
presetDescription,
notifyMissingRequirements,
setupTriggerMutation,
executeGraphMutation,
toast,
]);
const hasInputFields = useMemo(() => {
return Object.keys(agentInputFields).length > 0;
}, [agentInputFields]);
return {
// UI state
isOpen,
setIsOpen,
// Run mode
defaultRunType: defaultRunType as RunVariant,
// Form: regular inputs
inputValues,
setInputValues,
// Form: credentials
inputCredentials,
setInputCredentials,
// Preset/trigger labels
presetName,
presetDescription,
setPresetName,
setPresetDescription,
// Validation/readiness
allRequiredInputsAreSet,
missingInputs,
// Schemas for rendering
agentInputFields,
agentCredentialsInputFields,
hasInputFields,
// Async states
isExecuting: executeGraphMutation.isPending,
isSettingUpTrigger: setupTriggerMutation.isPending,
// Actions
handleRun,
};
}

View File

@@ -1,17 +1,58 @@
"use client";
import { getV1GetGraphVersion } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { exportAsJSONFile } from "@/lib/utils";
import { formatDate } from "@/lib/utils/time";
import Link from "next/link";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
type Props = {
agent: LibraryAgent;
onRun?: (run: GraphExecutionMeta) => void;
onTriggerSetup?: (preset: LibraryAgentPreset) => void;
onScheduleCreated?: (schedule: GraphExecutionJobInfo) => void;
};
export function EmptyTasks({ agent }: Props) {
export function EmptyTasks({
agent,
onRun,
onTriggerSetup,
onScheduleCreated,
}: Props) {
const { toast } = useToast();
async function handleExport() {
try {
const res = await getV1GetGraphVersion(
agent.graph_id,
agent.graph_version,
{ for_export: true },
);
if (res.status === 200) {
const filename = `${agent.name}_v${agent.graph_version}.json`;
exportAsJSONFile(res.data as any, filename);
toast({ title: "Agent exported" });
} else {
toast({ title: "Failed to export agent", variant: "destructive" });
}
} catch (e: any) {
toast({
title: "Failed to export agent",
description: e?.message,
variant: "destructive",
});
}
}
const isPublished = Boolean(agent.marketplace_listing);
const createdAt = formatDate(agent.created_at);
const updatedAt = formatDate(agent.updated_at);
@@ -45,6 +86,9 @@ export function EmptyTasks({ agent }: Props) {
</Button>
}
agent={agent}
onRunCreated={onRun}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</div>
</div>
@@ -92,10 +136,15 @@ export function EmptyTasks({ agent }: Props) {
) : null}
</div>
<div className="mt-4 flex items-center gap-2">
<Button variant="secondary" size="small">
Edit agent
<Button variant="secondary" size="small" asChild>
<Link
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
target="_blank"
>
Edit agent
</Link>
</Button>
<Button variant="secondary" size="small">
<Button variant="secondary" size="small" onClick={handleExport}>
Export agent to file
</Button>
</div>

View File

@@ -0,0 +1,323 @@
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {
return (
<div className="flex h-full flex-col items-center justify-center gap-20">
<div>
<svg
width="342"
height="211"
viewBox="0 0 342 211"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M212.148 114.272C193.65 114.272 175.622 86.479 175.622 86.479L169.237 78.4039L164.636 72.6762L163.979 71.8311L136.843 37.5588L135.247 35.4931C134.683 34.8358 134.026 34.0847 133.369 33.2396C128.58 26.9485 122.383 15.8687 126.702 10.0471C131.585 3.38044 145.857 -1.3144 155.998 2.15978C166.139 5.63396 170.646 10.5166 170.646 10.5166C170.646 10.5166 184.918 12.3945 193.65 40.1879C202.383 67.9814 227.078 55.7748 236.467 80.9391C242.571 97.3711 230.552 114.366 212.054 114.366L212.148 114.272Z"
fill="#FF9C0F"
/>
<path
d="M212.148 114.554C193.744 114.554 175.622 86.8547 175.434 86.573L135.059 35.5871C134.496 34.9298 133.932 34.1787 133.181 33.2397C121.913 18.3101 125.294 11.4557 126.514 9.67163C131.397 3.09885 145.669 -1.97157 156.092 1.6904C165.481 4.88289 170.176 9.38994 170.74 10.0472C171.397 10.1411 174.683 10.8923 179.003 14.5543C183.322 18.2162 189.331 25.5402 193.838 40.0003C198.251 54.2726 207.172 57.9345 215.716 61.5026C223.791 64.8829 232.148 68.3571 236.749 80.6575C239.66 88.451 238.627 96.7139 234.026 103.381C229.143 110.423 221.162 114.46 212.148 114.46C212.148 114.46 212.148 114.46 212.054 114.46L212.148 114.554ZM148.392 1.12703C139.754 1.12703 130.646 5.16458 126.984 10.1411C123.134 15.3054 127.923 25.3524 133.65 32.958C134.308 33.897 134.965 34.6481 135.434 35.2115L137.031 37.2773L175.81 86.2913C175.998 86.573 193.932 113.897 211.96 113.991C211.96 113.991 211.96 113.991 212.054 113.991C220.88 113.991 228.768 110.047 233.557 103.193C238.064 96.7139 239.096 88.5449 236.28 81.0331C231.772 69.0144 223.885 65.728 215.622 62.1599C206.984 58.4979 197.97 54.7421 193.463 40.282C188.956 26.0097 183.04 18.7796 178.815 15.2115C174.308 11.3618 170.74 10.7984 170.646 10.7984C170.646 10.7984 170.552 10.7984 170.458 10.7984C170.458 10.7984 165.857 5.91576 155.904 2.53548C153.557 1.69041 150.927 1.31482 148.298 1.31482L148.392 1.12703Z"
fill="#101720"
/>
<path
d="M212.148 114.272C193.651 114.272 175.622 86.479 175.622 86.479H170.176L169.143 78.4039L168.862 76.4321L174.59 68.3569C184.261 79.5306 205.294 90.986 219.848 88.9203C234.402 86.7607 236.28 80.9391 236.28 80.9391C242.383 97.371 230.364 114.366 211.867 114.366L212.148 114.272Z"
fill="#101720"
/>
<path
d="M212.148 114.554C194.402 114.554 176.843 88.8264 175.528 86.7606H170.27C170.082 86.7606 169.988 86.6668 169.988 86.479L168.768 76.432C168.768 76.432 168.768 76.3381 168.768 76.2442L174.496 68.1691C174.496 68.1691 174.589 68.0752 174.683 68.0752C174.683 68.0752 174.871 68.0752 174.871 68.1691C184.73 79.5306 205.669 90.7043 219.942 88.5447C234.12 86.479 236.186 80.8451 236.186 80.7513C236.186 80.6574 236.373 80.5635 236.467 80.5635C236.467 80.5635 236.655 80.5635 236.655 80.6573C239.566 88.4508 238.533 96.7137 233.932 103.38C229.05 110.423 221.068 114.46 212.054 114.46L212.148 114.554ZM170.458 86.1973H175.622C175.716 86.1973 175.81 86.1973 175.904 86.2912C176.092 86.5728 194.026 113.897 212.054 113.991C220.881 113.991 228.768 110.047 233.557 103.193C237.97 96.8076 239.003 89.0142 236.467 81.5963C235.528 83.1926 231.96 87.4179 220.035 89.1081C205.669 91.2677 184.73 80.1879 174.683 68.7325L169.143 76.432L170.364 86.1973H170.458Z"
fill="#101720"
/>
<path
d="M131.021 51.3617C131.021 51.3617 129.519 63.5683 134.777 69.0143C140.411 74.8359 154.402 73.5213 154.402 73.5213L152.524 86.4791H175.528L174.683 68.2631L167.078 57.3711C174.308 52.864 171.866 44.6011 167.453 42.1598C166.327 41.5964 165.106 41.3147 163.885 41.5964C157.782 42.911 158.345 53.1457 158.345 53.1457H154.402L150.552 39.0612C145.012 36.6199 138.251 33.5213 135.247 24.8828C135.247 25.1645 133.932 28.4509 133.463 32.6762C132.054 44.3194 127.266 45.0706 126.796 47.8875C126.327 50.6105 131.115 51.3617 131.115 51.3617H131.021Z"
fill="#F48282"
/>
<mask
id="mask0_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="126"
y="24"
width="50"
height="63"
>
<path
d="M131.021 51.3615C131.021 51.3615 129.519 63.5681 134.777 69.0141C140.411 74.8357 154.402 73.5212 154.402 73.5212L152.524 86.4789H175.528L174.683 68.3568L167.078 57.4648C174.308 52.9578 171.866 44.6949 167.453 42.2536C166.327 41.5963 165.106 41.4085 163.885 41.6902C157.782 43.0047 158.345 53.2395 158.345 53.2395H154.402L150.552 39.155C145.012 36.7137 138.251 33.6151 135.247 24.9766C135.247 25.2583 133.932 28.5446 133.463 32.77C132.054 44.4132 127.266 45.1644 126.796 47.9813C126.327 50.7043 131.115 51.4554 131.115 51.4554L131.021 51.3615Z"
fill="white"
/>
</mask>
<g mask="url(#mask0_912_14740)">
<path
d="M154.402 53.2393C154.402 53.2393 160.975 56.3378 161.162 61.9716C161.35 70.3284 149.425 72.3942 142.007 72.6759C132.148 73.0515 145.763 87.6993 145.763 87.6993C145.763 87.6993 170.928 84.5068 171.491 84.5068C172.054 84.5068 183.979 83.0984 183.979 83.0984C183.979 83.0984 181.35 47.6993 181.162 47.4176C180.975 47.136 170.176 36.0562 170.176 36.0562L157.313 42.1594L154.402 53.1454"
fill="#2973E8"
/>
</g>
<path
d="M175.622 86.7604H152.618C152.618 86.7604 152.43 86.7604 152.43 86.6665V86.4787L154.214 73.8966C151.96 74.0844 139.942 74.7416 134.683 69.2956C130.646 65.0703 130.552 56.8074 130.646 53.4271C130.646 52.582 130.646 52.0186 130.74 51.7369C129.988 51.5491 127.829 51.0796 126.89 49.7651C126.514 49.2017 126.327 48.6383 126.42 47.9811C126.608 47.0421 127.172 46.2909 128.017 45.3519C129.613 43.474 132.054 40.6571 133.087 33.1454C133.087 33.0515 133.087 32.8637 133.087 32.7698C133.369 30.2346 134.026 27.5116 134.871 25.0703C134.871 24.8825 135.059 24.7886 135.153 24.7886C135.247 24.7886 135.341 24.7886 135.434 24.9764C138.439 33.521 145.2 36.5257 150.552 38.967C150.552 38.967 150.74 39.0609 150.74 39.1548L154.496 52.9576H157.97C157.97 51.1736 157.97 42.6289 163.791 41.4083C165.012 41.1266 166.327 41.4083 167.641 42.0656C170.176 43.474 171.866 46.5726 171.96 49.6712C171.96 52.8637 170.458 55.6806 167.547 57.5585L174.965 68.1689C174.965 68.1689 174.965 68.2627 174.965 68.3566L175.904 86.5726V86.7604C175.904 86.7604 175.81 86.7604 175.716 86.7604H175.622ZM152.899 86.197H175.341L174.496 68.3566L166.984 57.5585V57.3707C166.984 57.3707 166.984 57.1829 167.078 57.1829C169.988 55.305 171.585 52.6759 171.491 49.5773C171.491 46.6665 169.801 43.7557 167.453 42.4412C166.327 41.8778 165.2 41.5961 164.073 41.8778C158.251 43.0984 158.721 53.0515 158.721 53.1454V53.3332C158.721 53.3332 158.627 53.3332 158.533 53.3332H154.589C154.496 53.3332 154.308 53.3332 154.308 53.1454L150.458 39.2487C145.106 36.8073 138.533 33.8966 135.341 25.7275C134.589 27.9811 134.12 30.4224 133.838 32.6759C133.838 32.7698 133.838 32.9576 133.838 33.0515C132.805 40.6571 130.364 43.5679 128.674 45.4459C127.923 46.2909 127.359 46.9482 127.172 47.7933C127.172 48.2628 127.172 48.7322 127.453 49.1078C128.486 50.4224 131.209 50.8919 131.209 50.8919C131.397 50.8919 131.491 51.0796 131.491 51.1735C131.491 51.1735 131.491 51.9247 131.397 53.1454C131.209 56.4318 131.397 64.6008 135.247 68.6383C140.693 74.2721 154.496 73.0515 154.683 73.0515H154.871V73.2393L152.993 85.9153L152.899 86.197Z"
fill="#101720"
/>
<path
d="M134.402 42.7228C134.402 42.7228 136.092 39.6242 140.129 40.1876C144.167 40.751 144.73 43.3801 144.73 43.3801C144.73 43.3801 142.946 46.5726 139.284 46.1031C135.622 45.6336 134.402 42.7228 134.402 42.7228Z"
fill="#FFFFFE"
/>
<mask
id="mask1_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="134"
y="40"
width="11"
height="7"
>
<path
d="M134.402 42.7228C134.402 42.7228 136.092 39.6242 140.129 40.1876C144.167 40.751 144.73 43.3801 144.73 43.3801C144.73 43.3801 142.946 46.5726 139.284 46.1031C135.622 45.6336 134.402 42.7228 134.402 42.7228Z"
fill="white"
/>
</mask>
<g mask="url(#mask1_912_14740)">
<path
d="M138.064 46.2909C139.516 46.2909 140.693 45.1138 140.693 43.6618C140.693 42.2098 139.516 41.0327 138.064 41.0327C136.612 41.0327 135.435 42.2098 135.435 43.6618C135.435 45.1138 136.612 46.2909 138.064 46.2909Z"
fill="#101720"
/>
</g>
<path
d="M139.19 60.1877C139.002 60.1877 138.908 60.0938 138.908 59.906C138.908 58.4037 139.378 57.2769 140.129 56.7136C140.88 56.1502 142.007 56.1502 143.322 56.7136C143.509 56.7136 143.509 56.9013 143.509 57.0891C143.509 57.2769 143.322 57.2769 143.134 57.2769C142.007 56.8074 141.068 56.8074 140.411 57.2769C139.754 57.7464 139.378 58.6854 139.378 59.9999C139.378 61.3145 139.284 60.2816 139.096 60.2816L139.19 60.1877Z"
fill="#101720"
/>
<path
d="M144.543 39.437C144.543 39.437 144.355 39.437 144.355 39.3431C143.604 38.3102 142.101 37.559 140.317 37.3712C138.345 37.1834 136.561 37.7468 135.435 38.7797C135.341 38.8736 135.153 38.8736 135.059 38.7797C134.965 38.6858 134.965 38.498 135.059 38.4041C136.28 37.1834 138.251 36.6201 140.411 36.8079C142.383 36.9957 143.979 37.8407 144.824 39.0614C144.824 39.1553 144.824 39.3431 144.824 39.437C144.824 39.437 144.73 39.437 144.636 39.437H144.543Z"
fill="#101720"
/>
<path
d="M140.223 40.0942C140.223 40.0942 142.101 40.1881 143.51 41.4088C144.918 42.6294 145.294 43.5684 145.763 43.7562L145.2 44.4135C145.2 44.4135 142.946 40.8454 140.223 40.1881V40.0942Z"
fill="#101720"
/>
<path
d="M135.246 25.2581C135.153 25.2581 135.059 25.2581 134.965 25.0703C134.683 24.2252 134.401 23.3801 134.214 22.3473C134.214 22.1595 134.214 22.0656 134.401 21.9717C134.589 21.9717 134.683 21.9717 134.777 22.1595C134.965 23.0984 135.246 23.9435 135.528 24.7886C135.528 24.9764 135.528 25.0703 135.34 25.1642C135.34 25.1642 135.34 25.1642 135.246 25.1642V25.2581Z"
fill="#101720"
/>
<path
d="M154.495 73.5211C162.101 72.9577 168.955 70.3286 168.955 70.3286L153.744 78.5915L154.495 73.5211Z"
fill="#101720"
/>
<path
d="M153.744 78.7794C153.744 78.7794 153.65 78.7794 153.556 78.7794C153.556 78.7794 153.462 78.5916 153.462 78.4977L154.214 73.4273C154.214 73.3334 154.307 73.2395 154.495 73.1456C162.007 72.5822 168.861 69.9531 168.955 69.9531C169.049 69.9531 169.237 69.9531 169.331 70.1409C169.331 70.2348 169.331 70.4226 169.237 70.5165L154.026 78.7794C154.026 78.7794 154.026 78.7794 153.932 78.7794H153.744ZM154.777 73.8029L154.12 78.0282L166.138 71.5494C163.415 72.3945 159.19 73.4273 154.777 73.8029Z"
fill="#101720"
/>
<path
d="M161.35 49.5773C161.162 49.5773 161.068 49.3895 161.162 49.2018C161.82 46.6665 163.416 45.8215 164.355 45.6337C165.669 45.2581 167.078 45.6337 167.923 46.5726C168.017 46.6665 168.017 46.8543 167.923 46.9482C167.829 47.0421 167.641 47.0421 167.547 46.9482C166.89 46.197 165.669 45.9154 164.543 46.1971C163.791 46.3849 162.383 47.0421 161.82 49.3895C161.82 49.4834 161.632 49.5773 161.538 49.5773H161.35Z"
fill="#101720"
/>
<path
d="M164.448 52.5823C164.448 52.5823 164.26 52.5823 164.26 52.4884C164.26 52.3945 164.26 52.2067 164.26 52.1128C166.514 50.4227 165.199 46.0095 165.199 45.9156C165.199 45.7278 165.199 45.6339 165.387 45.54C165.575 45.54 165.669 45.5401 165.763 45.7278C165.763 45.9156 167.265 50.6105 164.636 52.4884C164.636 52.4884 164.542 52.4884 164.448 52.4884V52.5823Z"
fill="#101720"
/>
<path
d="M131.022 51.3618L133.463 51.7374L130.928 53.3336L131.022 51.3618Z"
fill="#101720"
/>
<path
d="M130.928 53.6151C130.928 53.6151 130.834 53.6151 130.74 53.6151C130.74 53.6151 130.552 53.4274 130.646 53.3335L130.834 51.3616C130.834 51.3616 130.834 51.1738 130.928 51.1738C130.928 51.1738 131.022 51.1738 131.115 51.1738L133.463 51.4555C133.557 51.4555 133.651 51.5494 133.745 51.6433C133.838 51.7372 133.745 51.8311 133.651 51.925L131.115 53.6151C131.115 53.6151 131.022 53.6151 130.928 53.6151ZM131.303 51.7372V52.864L132.618 51.925L131.303 51.7372Z"
fill="#101720"
/>
<path
d="M167.547 14.2725C167.547 14.2725 168.205 26.479 173.463 31.5494C178.721 36.6199 193.557 40.1879 193.557 40.1879L190.928 32.9579L167.453 14.2725H167.547Z"
fill="#101720"
/>
<path
d="M193.65 40.3757C193.087 40.1879 178.721 36.8076 173.369 31.7372C168.111 26.5729 167.359 14.7419 167.359 14.2724C167.359 14.1785 167.359 14.0846 167.547 13.9907C167.641 13.9907 167.735 13.9907 167.829 13.9907L191.303 32.6762C191.303 32.6762 191.303 32.6762 191.303 32.7701L193.932 40.0001C193.932 40.094 193.932 40.1879 193.932 40.2818C193.932 40.2818 193.838 40.3757 193.744 40.3757H193.65ZM167.923 14.8358C168.111 17.371 169.331 27.0424 173.744 31.3616C178.345 35.7748 190.552 39.0611 193.181 39.7184L190.834 33.0518L167.923 14.7419V14.8358Z"
fill="#101720"
/>
<path
d="M165.2 6.29084C165.2 6.29084 163.979 16.2439 170.834 24.9763C177.688 33.7087 191.115 32.9575 191.115 32.9575C191.115 32.9575 204.824 15.7744 193.745 6.29084C177.594 -7.51198 165.2 6.29084 165.2 6.29084Z"
fill="#FF9C0F"
/>
<path
d="M190.27 33.2395C189.049 33.2395 186.42 33.2395 183.416 32.4883C179.566 31.6432 174.12 29.6714 170.552 25.1644C164.073 16.9015 164.73 7.32399 164.824 6.38503V6.19724C164.918 6.10334 177.594 -7.69948 193.744 6.19724C204.824 15.6808 191.209 33.0517 191.115 33.2395C191.115 33.2395 191.021 33.3334 190.927 33.3334C190.927 33.3334 190.646 33.3334 190.176 33.3334L190.27 33.2395ZM165.481 6.38503C165.388 7.41789 164.73 16.7137 171.021 24.7888C177.312 32.8639 189.519 32.6761 190.927 32.6761C192.054 31.2677 203.791 15.3052 193.557 6.57282C178.251 -6.47883 166.608 5.25827 165.481 6.47893V6.38503Z"
fill="#101720"
/>
<path
d="M194.12 27.5121C189.519 27.5121 176.937 23.7563 173.463 15.1178C173.463 14.93 173.463 14.8361 173.65 14.7422C173.838 14.7422 173.932 14.7422 174.026 14.93C177.594 23.5685 190.927 27.2304 194.683 26.9488C194.871 26.9488 194.965 27.0426 194.965 27.2304C194.965 27.4182 194.871 27.5121 194.683 27.5121C194.496 27.5121 194.308 27.5121 194.12 27.5121Z"
fill="#101720"
/>
<path
d="M327.547 78.0281C327.547 78.0281 316.843 189.577 289.895 203.005C262.946 216.526 234.777 185.915 234.777 185.915V209.953H103.228V187.136C103.228 187.136 51.7726 204.319 39.0965 176.15C26.4204 147.981 13.2749 78.0281 13.2749 78.0281L65.2937 77.8403L84.9181 138.122C84.9181 138.122 107.547 110.422 134.026 90.2347C160.411 70.0469 197.782 78.2159 214.026 93.3333C230.364 108.451 259.848 148.92 259.848 148.92L275.247 78.122H327.547V78.0281Z"
fill="#FFFFFE"
/>
<path
d="M234.777 210.235H103.228C103.04 210.235 102.946 210.141 102.946 209.953V187.512C100.693 188.169 88.862 191.831 75.9042 192.488C56.8432 193.521 44.0732 187.887 38.815 176.244C26.3267 148.357 13.0873 78.7793 12.9934 78.0281V77.8403C12.9934 77.8403 13.0873 77.7464 13.1812 77.7464L65.2 77.5586C65.2939 77.5586 65.3878 77.5586 65.4817 77.7464L85.0122 137.559C87.923 134.084 109.425 108.732 133.838 90.0468C161.069 69.2018 198.627 78.6854 214.214 93.1455C229.425 107.23 256.28 143.568 259.754 148.263L274.965 78.0281C274.965 77.9342 275.153 77.8403 275.247 77.8403H327.547C327.735 77.8403 327.829 77.9342 327.829 78.122C327.829 78.3098 327.829 78.122 327.829 78.2159C327.641 79.8121 324.918 107.136 319.097 135.587C311.022 174.929 301.256 197.746 289.989 203.38C265.106 215.868 238.909 190.704 234.965 186.76V210.047C234.965 210.235 234.871 210.329 234.684 210.329L234.777 210.235ZM103.698 210.235H234.59V185.915C234.59 185.821 234.59 185.728 234.777 185.634C234.871 185.634 234.965 185.634 235.059 185.634C235.341 185.915 263.416 215.868 289.895 202.629C315.716 189.765 326.702 84.6947 327.36 78.122H275.623L260.317 148.732C260.317 148.826 260.223 148.92 260.13 148.92C260.036 148.92 259.942 148.92 259.848 148.826C259.566 148.451 230.177 108.263 214.026 93.3332C198.533 78.967 161.256 69.6713 134.308 90.2346C108.205 110.235 85.4817 137.84 85.2939 138.122C85.2939 138.122 85.1061 138.216 85.0122 138.216C84.9183 138.216 84.8244 138.216 84.8244 138.028L65.2 77.9342L13.7446 78.122C14.7774 83.3802 27.4535 149.014 39.4723 175.868C51.8666 203.474 102.759 186.854 103.322 186.667C103.322 186.667 103.51 186.667 103.604 186.667C103.604 186.667 103.698 186.76 103.698 186.854V209.39V210.235Z"
fill="#101720"
/>
<path
d="M104.261 156.338C103.697 164.977 103.322 175.587 103.322 187.137L98.7209 188.263L104.355 156.338H104.261Z"
fill="#101720"
/>
<path
d="M98.7207 188.545C98.7207 188.545 98.6268 188.545 98.5329 188.545C98.5329 188.545 98.439 188.357 98.5329 188.263L104.073 156.432C104.073 156.338 104.073 156.244 104.261 156.15C104.355 156.15 104.448 156.15 104.542 156.15C104.542 156.15 104.636 156.338 104.542 156.432C103.885 166.949 103.603 177.277 103.603 187.136C103.603 196.995 103.603 187.418 103.416 187.418L98.8146 188.545H98.7207ZM103.697 161.221L99.0024 187.887L102.946 186.855C102.946 178.498 103.228 169.953 103.603 161.221H103.697Z"
fill="#101720"
/>
<path
d="M104.261 156.62C104.073 156.62 103.979 156.432 103.979 156.338C104.824 143.286 105.857 134.648 105.857 134.648C105.857 134.46 106.045 134.366 106.139 134.366C106.327 134.366 106.421 134.554 106.421 134.648C106.421 134.648 105.388 143.38 104.543 156.338C104.543 156.526 104.449 156.62 104.261 156.62Z"
fill="#101720"
/>
<path
d="M240.411 191.174L234.778 185.916C234.778 174.93 234.214 163.38 233.557 153.709L240.411 191.08V191.174Z"
fill="#101720"
/>
<path
d="M240.411 191.456C240.411 191.456 240.317 191.456 240.223 191.456L234.589 186.197C234.589 186.197 234.589 186.103 234.589 186.01C234.589 176.714 234.214 165.916 233.369 153.897C233.369 153.709 233.463 153.615 233.65 153.615C233.838 153.615 233.932 153.615 233.932 153.803L240.786 191.174C240.786 191.268 240.786 191.456 240.599 191.456C240.599 191.456 240.599 191.456 240.505 191.456H240.411ZM235.059 185.822L239.941 190.423L234.12 158.592C234.777 168.639 235.059 177.841 235.059 185.822Z"
fill="#101720"
/>
<path
d="M233.557 154.085C233.369 154.085 233.275 153.991 233.275 153.803C232.336 140.658 231.303 130.705 231.303 130.611C231.303 130.423 231.303 130.329 231.585 130.329C231.773 130.329 231.867 130.423 231.867 130.611C231.867 130.705 232.993 140.564 233.932 153.803C233.932 153.991 233.838 154.085 233.651 154.085H233.557Z"
fill="#101720"
/>
<path
d="M233.556 154.084C233.369 154.084 233.275 153.99 233.275 153.803C233.275 153.615 233.369 153.521 233.556 153.521C233.744 153.521 233.838 153.615 233.838 153.803C233.838 153.99 233.744 154.084 233.556 154.084Z"
fill="#101720"
/>
<path
d="M260.505 158.967C256.092 158.967 249.989 149.672 244.73 141.597C243.322 139.531 242.007 137.465 240.787 135.587C240.129 134.648 239.378 133.428 238.439 131.925C229.801 118.498 206.702 82.9112 172.054 82.9112C137.406 82.9112 169.425 82.9111 168.111 83.0989C138.909 85.3525 107.923 118.874 91.3971 136.808C86.1389 142.536 81.5379 147.418 80.505 147.418C78.0637 145.822 61.444 104.507 51.491 78.2163H65.106L84.9182 138.874L85.3877 138.31C85.5755 138.029 108.298 110.423 134.402 90.5168C144.637 82.7233 157.313 78.5919 171.209 78.5919C185.106 78.5919 204.073 84.5074 213.932 93.6154C229.989 108.545 259.472 148.733 259.754 149.108L260.317 149.953L275.81 78.498H292.618C289.237 80.0003 282.759 83.0989 281.726 86.3853C281.35 87.4182 280.505 91.6435 279.097 98.0285C274.965 117.09 266.327 156.808 262.007 158.78C261.632 158.967 261.162 159.061 260.787 159.061L260.505 158.967Z"
fill="#D5C0FC"
/>
<path
d="M13.2748 78.028L65.2935 77.8403C65.2935 77.8403 61.7255 68.3567 67.0776 53.1454C70.5518 43.2863 64.8241 39.906 61.256 41.1266C57.7818 42.3473 53.5565 48.3567 53.5565 48.3567C53.5565 48.3567 47.641 44.9764 43.6973 40.3755C39.7536 35.8684 38.9086 33.0515 36.0917 33.2393C33.2748 33.4271 31.5846 35.1172 32.3358 38.028C32.3358 38.028 30.1762 34.9294 26.2325 34.7417C22.2889 34.5539 22.3827 39.1548 22.3827 39.1548C22.3827 39.1548 16.5611 35.0233 12.7114 36.5257C8.86162 38.028 10.364 42.629 10.364 42.629C10.364 42.629 6.98369 38.028 3.41561 39.5304C-0.0585646 41.0327 -1.8426 48.075 2.94613 55.305C7.73486 62.5351 11.4907 68.9201 13.1809 78.2158L13.2748 78.028Z"
fill="#F48282"
/>
<mask
id="mask2_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="0"
y="33"
width="69"
height="46"
>
<path
d="M13.2748 78.028L65.2935 77.8403C65.2935 77.8403 61.7255 68.3567 67.0776 53.1454C70.5518 43.2863 64.8241 39.906 61.256 41.1266C57.7818 42.3473 53.5565 48.3567 53.5565 48.3567C53.5565 48.3567 47.641 44.9764 43.6973 40.3755C39.7536 35.8684 38.9086 33.0515 36.0917 33.2393C33.3687 33.4271 31.5846 35.1172 32.3358 37.9341C32.3358 37.9341 30.1762 34.8356 26.2325 34.6478C22.3828 34.46 22.3827 39.0609 22.3827 39.0609C22.3827 39.0609 16.5611 34.9294 12.7114 36.4318C8.86162 37.9341 10.364 42.5351 10.364 42.5351C10.364 42.5351 6.98369 37.9341 3.41561 39.4365C-0.0585646 40.9388 -1.8426 47.9811 2.94613 55.2111C7.73486 62.4412 11.4907 68.8262 13.1809 78.1219L13.2748 78.028Z"
fill="white"
/>
</mask>
<g mask="url(#mask2_912_14740)">
<path
d="M33.5564 33.2389C36.1856 33.2389 39.5658 39.6239 42.6644 43.4737C45.763 47.3234 48.1104 49.1075 47.641 50.7976C47.1715 52.4878 45.763 53.5206 45.763 53.5206C45.763 53.5206 54.0259 50.6098 53.5564 47.0418C53.087 43.4737 59.0964 38.3094 59.0964 38.3094C59.0964 38.3094 66.2325 40.8446 65.1057 45.915C63.979 50.9854 59.2842 56.2436 59.0964 63.8493C58.9086 71.4549 59.1903 77.5582 55.7161 80.2812C52.2419 82.9103 70.364 80.7507 70.364 80.7507L73.3687 59.8117L70.7395 34.5535L37.8757 26.5723L33.3687 33.3328L33.5564 33.2389Z"
fill="#2973E8"
/>
<path
d="M24.1669 34.2717C26.3265 34.6473 26.9838 36.1496 29.519 40.1872C32.0542 44.2247 34.965 48.0745 37.0307 49.3891C39.0965 50.7036 40.411 48.6379 39.2843 47.4172C38.1575 46.1966 35.6223 43.2858 34.4016 41.3139C33.181 39.3421 31.303 35.3984 31.303 35.3984L26.6082 31.6426L24.073 34.2717H24.1669Z"
fill="#2973E8"
/>
<path
d="M12.2423 36.0556C14.3081 36.0556 16.0921 38.3091 19.3785 42.5345C22.6649 46.7598 25.3879 49.7645 26.9841 50.7974C28.6743 51.8303 32.0545 51.3608 30.1766 49.2012C28.2048 47.0415 25.7635 44.4124 24.5428 42.5345C23.3222 40.6566 21.7259 38.0274 21.7259 38.0274L18.5334 33.896L12.1484 36.1495L12.2423 36.0556Z"
fill="#2973E8"
/>
<path
d="M2.5708 39.0605C5.01212 39.6239 5.01211 39.3422 7.54732 44.1309C10.0825 48.9197 12.6177 52.2999 15.0591 53.6145C17.5004 54.9291 21.3501 54.3657 18.2515 52.1121C15.153 49.8586 13.3689 47.2295 12.5239 45.8211C11.5849 44.4126 9.23747 39.3422 9.23747 39.3422L5.10601 36.9009L2.6647 38.9666L2.5708 39.0605Z"
fill="#2973E8"
/>
<path
d="M59.754 67.5112C59.5662 68.9197 59.1906 70.0464 58.7211 70.9854C56.3737 72.8633 53.369 73.7084 50.4582 74.3657C49.8948 74.4596 50.0826 75.3986 50.7399 75.3047C52.9934 74.8352 55.247 74.2718 57.2188 73.2389C54.4958 75.7741 49.1437 76.807 37.8761 78.4032C20.693 80.8446 60.1296 82.0652 60.1296 82.0652L60.9747 70.6098L59.754 67.699V67.5112Z"
fill="#2973E8"
/>
<path
d="M48.1105 74.4595C47.5471 74.4595 47.5471 75.3984 48.1105 75.3984C48.6739 75.3984 48.6739 74.4595 48.1105 74.4595Z"
fill="#2973E8"
/>
</g>
<path
d="M13.2748 78.31H13.087C13.087 78.31 13.087 78.2161 13.087 78.1222C11.303 68.7325 7.45323 62.2537 2.94619 55.3053C0.317083 51.2678 -0.621889 46.7607 0.410975 43.1926C0.974356 41.2208 2.10112 39.8123 3.50957 39.249C6.23257 38.1222 8.86168 40.2818 10.0823 41.5964C9.89455 40.094 10.0823 37.371 12.8053 36.3382C16.1856 35.0236 20.8805 37.8405 22.2889 38.7795C22.2889 38.0283 22.6645 36.526 23.6974 35.4931C24.4485 34.8358 25.2936 34.5541 26.4204 34.5541C29.1434 34.648 31.1152 36.2443 32.0542 37.1832C32.0542 36.2443 32.242 35.4931 32.7114 34.8358C33.4626 33.803 34.6833 33.2396 36.2795 33.1457C38.3453 32.9579 39.472 34.4602 41.2561 36.8077C42.0072 37.8405 42.9462 38.9673 44.073 40.3757C47.5471 44.3194 52.5237 47.5119 53.6504 48.1692C54.4016 47.0424 58.0636 42.1598 61.3499 41.033C63.134 40.3757 65.1997 40.9391 66.7021 42.3476C68.0166 43.6621 69.8946 46.8546 67.5471 53.3335C62.195 68.2631 65.6692 77.7466 65.7631 77.8405C65.7631 77.8405 65.7631 78.0283 65.7631 78.1222C65.7631 78.2161 65.6692 78.2161 65.5753 78.2161L13.5565 78.4039L13.2748 78.31ZM5.01192 39.3429C4.54243 39.3429 4.16684 39.3429 3.69736 39.6246C2.4767 40.1879 1.44384 41.5025 0.880459 43.2865C-0.152405 46.7607 0.786561 51.08 3.32177 54.9297C7.82881 61.7842 11.7725 68.2631 13.5565 77.7466L64.918 77.5588C64.3546 75.587 62.1011 66.479 66.8898 53.0518C69.1434 46.8546 67.3593 43.8499 66.2326 42.6293C64.918 41.3147 63.134 40.8452 61.5377 41.4086C58.1575 42.6293 54.026 48.4509 54.026 48.5448C54.026 48.6386 53.7443 48.7325 53.6504 48.5448C53.6504 48.5448 47.641 45.0706 43.6974 40.5635C42.4767 39.1551 41.5377 37.9344 40.8805 36.9955C39.0964 34.648 38.1574 33.4274 36.3734 33.5213C34.965 33.6152 33.8382 34.1785 33.1809 35.0236C32.6175 35.7748 32.5237 36.8077 32.8053 37.9344C32.8053 38.0283 32.8053 38.2161 32.6175 38.2161C32.5236 38.2161 32.3359 38.2161 32.242 38.1222C32.242 38.1222 30.0823 35.1175 26.4204 34.9297C25.4814 34.9297 24.7302 35.1175 24.073 35.6809C22.8523 36.9015 22.7584 39.0612 22.7584 39.0612C22.7584 39.1551 22.7584 39.249 22.5706 39.3429C22.4767 39.3429 22.3828 39.3429 22.2889 39.3429C22.2889 39.3429 16.5612 35.3053 12.8992 36.7138C9.33117 38.1222 10.6457 42.2537 10.7396 42.4415C10.7396 42.5354 10.7396 42.7231 10.6457 42.817C10.5518 42.817 10.364 42.817 10.2701 42.817C10.2701 42.817 7.82882 39.5307 5.01192 39.5307V39.3429Z"
fill="#101720"
/>
<path
d="M38.5332 55.7746C38.4393 55.7746 38.2515 55.6807 38.2515 55.5868C38.2515 55.399 38.2515 55.3051 38.4393 55.3051C38.5332 55.3051 50.458 53.2393 53.2749 48.1689C53.2749 48.075 53.5566 47.9811 53.6505 48.075C53.7444 48.075 53.8383 48.3567 53.7444 48.4506C50.7397 53.7088 39.0027 55.7746 38.4393 55.7746H38.5332Z"
fill="#101720"
/>
<path
d="M40.2234 48.5447H40.0356C40.0356 48.5447 35.2469 43.9438 32.2422 38.1222C32.2422 38.0283 32.2422 37.8405 32.3361 37.7466C32.43 37.7466 32.6178 37.7466 32.7117 37.8405C35.7164 43.4743 40.4112 48.0752 40.4112 48.0752C40.5051 48.1691 40.5051 48.3569 40.4112 48.4508C40.4112 48.4508 40.3173 48.4508 40.2234 48.4508V48.5447Z"
fill="#101720"
/>
<path
d="M30.9276 50.2349H30.7398C30.7398 50.2349 24.6365 44.3194 22.2891 39.3429C22.2891 39.249 22.2891 39.0612 22.383 38.9673C22.4769 38.9673 22.6646 38.9673 22.7585 39.0612C25.106 43.9438 31.0215 49.7654 31.1154 49.8593C31.2093 49.9532 31.2093 50.141 31.1154 50.2349C31.1154 50.2349 31.0215 50.2349 30.9276 50.2349Z"
fill="#101720"
/>
<path
d="M19.6601 53.521C19.6601 53.521 19.5662 53.521 19.4723 53.521C19.4723 53.521 13.2751 49.1079 10.1765 42.629C10.1765 42.5351 10.1765 42.3473 10.2704 42.2534C10.3643 42.2534 10.5521 42.2534 10.646 42.3473C13.6507 48.6384 19.6601 52.9577 19.754 52.9577C19.8479 52.9577 19.9418 53.2393 19.754 53.3332L19.5662 53.4271L19.6601 53.521Z"
fill="#101720"
/>
<path
d="M327.547 78.028L275.528 77.8403C275.528 77.8403 279.096 68.3567 273.744 53.1454C270.27 43.2863 275.998 39.906 279.566 41.1266C283.04 42.3473 287.265 48.3567 287.265 48.3567C287.265 48.3567 293.181 44.9764 297.124 40.3755C301.068 35.8684 301.913 33.0515 304.73 33.2393C307.547 33.4271 309.237 35.1172 308.486 38.028C308.486 38.028 310.646 34.9294 314.589 34.7417C318.439 34.5539 318.439 39.1548 318.439 39.1548C318.439 39.1548 324.261 35.0233 328.11 36.5257C331.96 38.028 330.458 42.629 330.458 42.629C330.458 42.629 333.838 38.028 337.406 39.5304C340.88 41.0327 342.57 48.075 337.876 55.305C333.181 62.5351 329.331 68.9201 327.641 78.2158L327.547 78.028Z"
fill="#F48282"
/>
<mask
id="mask3_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="272"
y="33"
width="69"
height="46"
>
<path
d="M327.547 78.028L275.528 77.8403C275.528 77.8403 279.096 68.3567 273.744 53.1454C270.27 43.2863 275.998 39.906 279.566 41.1266C283.04 42.3473 287.265 48.3567 287.265 48.3567C287.265 48.3567 293.181 44.9764 297.124 40.3755C301.068 35.8684 301.913 33.0515 304.73 33.2393C307.453 33.4271 309.237 35.1172 308.486 37.9341C308.486 37.9341 310.646 34.8356 314.589 34.6478C318.439 34.46 318.439 39.0609 318.439 39.0609C318.439 39.0609 324.261 34.9294 328.11 36.4318C331.96 37.9341 330.458 42.5351 330.458 42.5351C330.458 42.5351 333.838 37.9341 337.406 39.4365C340.88 40.9388 342.57 47.9811 337.876 55.2111C333.181 62.4412 329.331 68.8262 327.641 78.1219L327.547 78.028Z"
fill="white"
/>
</mask>
<g mask="url(#mask3_912_14740)">
<path
d="M307.172 33.2389C304.542 33.2389 301.162 39.6239 298.064 43.4737C294.965 47.3234 292.618 49.1075 293.087 50.7976C293.557 52.4878 294.965 53.5206 294.965 53.5206C294.965 53.5206 286.702 50.6098 287.172 47.0418C287.641 43.4737 281.632 38.3094 281.632 38.3094C281.632 38.3094 274.496 40.8446 275.622 45.915C276.749 50.9854 281.444 56.2436 281.632 63.8493C281.819 71.4549 281.538 77.5582 285.012 80.2812C288.486 82.9103 270.364 80.7507 270.364 80.7507L267.359 59.8117L269.989 34.5535L302.852 26.5723L307.359 33.3328L307.172 33.2389Z"
fill="#2973E8"
/>
<path
d="M316.561 34.2717C314.401 34.6473 313.744 36.1496 311.115 40.1872C308.58 44.2247 305.669 48.0745 303.603 49.3891C301.538 50.7036 300.223 48.6379 301.35 47.4172C302.477 46.1966 305.012 43.2858 306.232 41.3139C307.453 39.3421 309.331 35.3984 309.331 35.3984L314.026 31.6426L316.561 34.2717Z"
fill="#2973E8"
/>
<path
d="M328.58 36.0556C326.514 36.0556 324.73 38.3091 321.444 42.5345C318.064 46.7598 315.435 49.7645 313.838 50.7974C312.242 51.8303 308.674 51.3608 310.646 49.2012C312.618 47.0415 315.059 44.4124 316.28 42.5345C317.5 40.6566 319.097 38.0274 319.097 38.0274L322.289 33.896L328.674 36.1495L328.58 36.0556Z"
fill="#2973E8"
/>
<path
d="M338.251 39.0605C335.81 39.6239 335.81 39.3422 333.275 44.1309C330.74 48.9197 328.204 52.2999 325.763 53.6145C323.322 54.9291 319.472 54.3657 322.571 52.1121C325.669 49.8586 327.453 47.2295 328.298 45.8211C329.143 44.4126 331.585 39.3422 331.585 39.3422L335.716 36.9009L338.157 38.9666L338.251 39.0605Z"
fill="#2973E8"
/>
<path
d="M280.974 67.5112C281.162 68.9197 281.444 70.0464 281.913 70.9854C284.261 72.8633 287.266 73.7084 290.176 74.3657C290.74 74.4596 290.552 75.3986 289.895 75.3047C287.641 74.8352 285.388 74.2718 283.416 73.2389C286.139 75.7741 291.491 76.807 302.759 78.4032C319.942 80.8446 280.505 82.0652 280.505 82.0652L279.66 70.6098L280.881 67.699L280.974 67.5112Z"
fill="#2973E8"
/>
<path
d="M292.618 74.4595C293.181 74.4595 293.181 75.3984 292.618 75.3984C292.054 75.3984 292.054 74.4595 292.618 74.4595Z"
fill="#2973E8"
/>
</g>
<path
d="M327.547 78.3097L275.528 78.1219C275.528 78.1219 275.34 78.1219 275.34 78.028C275.34 77.9341 275.34 77.8402 275.34 77.7463C275.34 77.6524 278.815 68.1688 273.556 53.2392C271.209 46.6665 273.087 43.5679 274.401 42.2533C275.81 40.8449 277.97 40.2815 279.754 40.9388C282.946 42.0655 286.608 46.9482 287.453 48.0749C288.58 47.4176 293.556 44.319 297.031 40.2815C298.251 38.9669 299.096 37.7463 299.848 36.7134C301.632 34.366 302.758 32.8636 304.824 33.0514C306.42 33.1453 307.735 33.8026 308.392 34.7416C308.862 35.3988 309.049 36.2439 309.049 37.089C309.988 36.15 311.866 34.5538 314.683 34.4599C315.81 34.4599 316.749 34.7416 317.406 35.3989C318.439 36.4317 318.721 37.9341 318.815 38.6852C320.223 37.7463 324.918 34.9294 328.298 36.2439C331.115 37.3707 331.303 39.9998 331.021 41.5021C332.242 40.1876 334.871 37.9341 337.594 39.1547C339.002 39.7181 340.129 41.2205 340.693 43.0984C341.725 46.6665 340.786 51.2674 338.157 55.2111C333.65 62.0655 329.707 68.5444 328.017 78.028C328.017 78.028 328.017 78.1219 328.017 78.2158C328.017 78.2158 327.923 78.2158 327.829 78.2158L327.547 78.3097ZM275.904 77.5585L327.265 77.7463C329.049 68.2627 332.993 61.7838 337.5 54.9294C340.035 51.0796 340.974 46.6665 339.941 43.2862C339.378 41.5021 338.439 40.1876 337.125 39.6242C333.838 38.2158 330.552 42.6289 330.552 42.6289C330.552 42.7228 330.364 42.8167 330.176 42.6289C330.082 42.6289 329.988 42.4411 330.082 42.2533C330.082 42.0655 331.491 37.9341 327.923 36.5256C324.261 35.1172 318.627 39.1547 318.533 39.1547C318.533 39.1547 318.345 39.1547 318.251 39.1547C318.251 39.1547 318.063 38.9669 318.063 38.873C318.063 38.7791 318.063 36.7134 316.749 35.4927C316.186 34.9294 315.34 34.6477 314.401 34.7416C310.74 34.9294 308.58 37.9341 308.58 37.9341C308.58 38.028 308.392 38.1219 308.204 38.028C308.11 38.028 308.017 37.8402 308.017 37.7463C308.298 36.6195 308.204 35.5866 307.641 34.8355C306.984 33.9904 305.857 33.427 304.448 33.3331C302.664 33.2392 301.725 34.366 299.941 36.8073C299.19 37.7463 298.251 39.0608 297.125 40.3754C293.181 44.8824 287.265 48.3566 287.171 48.3566C287.078 48.3566 286.89 48.3566 286.796 48.3566C286.796 48.3566 282.664 42.4411 279.284 41.2205C277.688 40.6571 275.81 41.2205 274.589 42.4411C273.369 43.6618 271.678 46.6665 273.838 52.8636C278.627 66.2909 276.373 75.4928 275.81 77.3707L275.904 77.5585Z"
fill="#101720"
/>
<path
d="M302.195 55.7747C301.725 55.7747 289.894 53.6151 286.89 48.4508C286.89 48.3569 286.89 48.1691 286.984 48.0752C287.077 48.0752 287.265 48.0752 287.359 48.1691C290.176 53.1456 302.101 55.2113 302.195 55.3052C302.383 55.3052 302.477 55.493 302.383 55.5869C302.383 55.6808 302.195 55.7747 302.101 55.7747H302.195Z"
fill="#101720"
/>
<path
d="M300.599 48.5449H300.411C300.317 48.451 300.317 48.2632 300.411 48.1693C300.411 48.1693 305.106 43.5684 308.111 37.9346C308.111 37.8407 308.392 37.7468 308.486 37.8406C308.58 37.8406 308.674 38.1223 308.58 38.2162C305.576 43.9439 300.787 48.5449 300.787 48.6388C300.787 48.6388 300.693 48.6388 300.599 48.6388V48.5449Z"
fill="#101720"
/>
<path
d="M309.894 50.2347H309.707C309.613 50.1408 309.613 49.953 309.707 49.8591C309.707 49.8591 315.716 44.0375 318.063 39.061C318.063 38.9671 318.345 38.8732 318.439 38.9671C318.533 38.9671 318.627 39.2488 318.533 39.3427C316.092 44.3192 310.082 50.2347 310.082 50.2347C310.082 50.2347 309.988 50.2347 309.894 50.2347Z"
fill="#101720"
/>
<path
d="M321.162 53.521C321.162 53.521 320.975 53.521 320.975 53.4271C320.975 53.3332 320.975 53.1454 320.975 53.0515C320.975 53.0515 327.078 48.7323 330.083 42.4412C330.083 42.2534 330.364 42.2534 330.458 42.3473C330.552 42.3473 330.646 42.629 330.552 42.7229C327.453 49.2018 321.35 53.521 321.256 53.6149C321.256 53.6149 321.162 53.6149 321.069 53.6149L321.162 53.521Z"
fill="#101720"
/>
</svg>
</div>
<div className="flex flex-col items-center gap-2">
<Text variant="h4" className="text-center text-[1.375rem]">
No triggers yet
</Text>
<Text variant="large" className="text-zinc-700">
Set up automatic triggers for your agent to run tasks automatically
they&apos;ll show up here.
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
type Props = {
children: React.ReactNode;
};
export function AnchorLinksWrap({ children }: Props) {
return (
<div className={cn(AGENT_LIBRARY_SECTION_PADDING_X, "hidden lg:block")}>
<nav className="flex gap-8 px-3 pb-1">{children}</nav>
</div>
);
}

View File

@@ -166,7 +166,7 @@ function renderMarkdown(
className="prose prose-sm dark:prose-invert max-w-none"
remarkPlugins={[
remarkGfm, // GitHub Flavored Markdown (tables, task lists, strikethrough)
remarkMath, // Math support for LaTeX
[remarkMath, { singleDollarTextMath: false }], // Math support for LaTeX
]}
rehypePlugins={[
rehypeKatex, // Render math with KaTeX

View File

@@ -15,7 +15,13 @@ export function RunDetailCard({ children, className, title }: Props) {
className,
)}
>
{title && <Text variant="lead-semibold">{title}</Text>}
{title ? (
typeof title === "string" ? (
<Text variant="lead-semibold">{title}</Text>
) : (
title
)
) : null}
{children}
</div>
);

View File

@@ -1,6 +1,7 @@
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { ClockClockwiseIcon } from "@phosphor-icons/react";
import moment from "moment";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { RunStatusBadge } from "../SelectedRunView/components/RunStatusBadge";
@@ -20,7 +21,20 @@ export function RunDetailHeader({ agent, run, scheduleRecurrence }: Props) {
<div className="flex w-full flex-col gap-0">
<div className="flex w-full flex-col flex-wrap items-start justify-between gap-1 md:flex-row md:items-center">
<div className="flex min-w-0 flex-1 flex-col items-start gap-3">
{run?.status ? <RunStatusBadge status={run.status} /> : null}
{run?.status ? (
<RunStatusBadge status={run.status} />
) : scheduleRecurrence ? (
<div className="inline-flex items-center gap-1 rounded-md bg-yellow-50 p-1">
<ClockClockwiseIcon
size={16}
className="text-yellow-700"
weight="bold"
/>
<Text variant="small-medium" className="text-yellow-700">
Scheduled
</Text>
</div>
) : null}
<Text variant="h2" className="truncate text-ellipsis">
{agent.name}
</Text>

View File

@@ -0,0 +1,11 @@
type Props = {
children: React.ReactNode;
};
export function SelectedActionsWrap({ children }: Props) {
return (
<div className="my-0 ml-4 flex flex-row items-center gap-3 lg:mx-0 lg:my-4 lg:flex-col">
{children}
</div>
);
}

View File

@@ -13,10 +13,11 @@ import {
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { InfoIcon } from "@phosphor-icons/react";
import { useEffect } from "react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -24,6 +25,7 @@ import { SelectedViewLayout } from "../SelectedViewLayout";
import { RunOutputs } from "./components/RunOutputs";
import { RunSummary } from "./components/RunSummary";
import { SelectedRunActions } from "./components/SelectedRunActions/SelectedRunActions";
import { WebhookTriggerSection } from "./components/WebhookTriggerSection";
import { useSelectedRunView } from "./useSelectedRunView";
const anchorStyles =
@@ -42,10 +44,11 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
}: Props) {
const { run, isLoading, responseError, httpError } = useSelectedRunView(
agent.graph_id,
runId,
);
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
const breakpoint = useBreakpoint();
const isLgScreenUp = isLargeScreen(breakpoint);
const {
pendingReviews,
@@ -90,51 +93,69 @@ export function SelectedRunView({
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />
{!isLgScreenUp ? (
<SelectedRunActions
agent={agent}
run={run}
onSelectRun={onSelectRun}
onClearSelectedRun={onClearSelectedRun}
/>
) : null}
{preset &&
agent.trigger_setup_info &&
preset.webhook_id &&
preset.webhook && (
<WebhookTriggerSection
preset={preset}
triggerSetupInfo={agent.trigger_setup_info}
/>
)}
{/* Navigation Links */}
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
<nav className="flex gap-8 px-3 pb-1">
{withSummary && (
<button
onClick={() => scrollToSection("summary")}
className={anchorStyles}
>
Summary
</button>
)}
<AnchorLinksWrap>
{withSummary && (
<button
onClick={() => scrollToSection("output")}
onClick={() => scrollToSection("summary")}
className={anchorStyles}
>
Output
Summary
</button>
)}
<button
onClick={() => scrollToSection("output")}
className={anchorStyles}
>
Output
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
{withReviews && (
<button
onClick={() => scrollToSection("input")}
onClick={() => scrollToSection("reviews")}
className={anchorStyles}
>
Your input
Reviews ({pendingReviews.length})
</button>
{withReviews && (
<button
onClick={() => scrollToSection("reviews")}
className={anchorStyles}
>
Reviews ({pendingReviews.length})
</button>
)}
</nav>
</div>
)}
</AnchorLinksWrap>
{/* Summary Section */}
{withSummary && (
<div id="summary" className="scroll-mt-4">
<RunDetailCard
title={
<div>
<div className="flex items-center gap-2">
<Text variant="lead-semibold">Summary</Text>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
size={8}
size={16}
className="cursor-help text-neutral-500 hover:text-neutral-700"
/>
</TooltipTrigger>
@@ -177,8 +198,8 @@ export function SelectedRunView({
<RunDetailCard title="Your input">
<AgentInputsReadOnly
agent={agent}
inputs={(run as any)?.inputs}
credentialInputs={(run as any)?.credential_inputs}
inputs={run?.inputs}
credentialInputs={run?.credential_inputs}
/>
</RunDetailCard>
</div>
@@ -206,14 +227,16 @@ export function SelectedRunView({
</div>
</SelectedViewLayout>
</div>
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
<SelectedRunActions
agent={agent}
run={run}
onSelectRun={onSelectRun}
onClearSelectedRun={onClearSelectedRun}
/>
</div>
{isLgScreenUp ? (
<div className="max-w-[3.75rem] flex-shrink-0">
<SelectedRunActions
agent={agent}
run={run}
onSelectRun={onSelectRun}
onClearSelectedRun={onClearSelectedRun}
/>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useState } from "react";
interface Props {
isOpen: boolean;
onClose: () => void;
onCreate: (name: string, description: string) => Promise<void>;
run?: GraphExecution;
}
export function CreateTemplateModal({ isOpen, onClose, onCreate }: Props) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
async function handleSubmit() {
if (!name.trim()) return;
setIsCreating(true);
try {
await onCreate(name.trim(), description.trim());
setName("");
setDescription("");
onClose();
} finally {
setIsCreating(false);
}
}
function handleCancel() {
setName("");
setDescription("");
onClose();
}
return (
<Dialog
controlled={{ isOpen, set: () => onClose() }}
styling={{ maxWidth: "500px" }}
>
<Dialog.Content>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Text variant="lead" as="h2" className="!font-medium !text-black">
Create Template
</Text>
<Text variant="body" className="text-zinc-600">
Save this task as a template to reuse later with the same inputs
and credentials.
</Text>
</div>
<div className="flex w-[96%] flex-col gap-4 pl-1">
<Input
id="template-name"
label="Name"
placeholder="Enter template name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
<Input
type="textarea"
id="template-description"
label="Description"
placeholder="Optional description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<Dialog.Footer className="mt-6">
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={!name.trim() || isCreating}
loading={isCreating}
>
Create Template
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -7,44 +7,55 @@ import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import {
ArrowBendLeftUpIcon,
ArrowBendRightDownIcon,
CardsThreeIcon,
EyeIcon,
StopIcon,
} from "@phosphor-icons/react";
import { AgentActionsDropdown } from "../../../AgentActionsDropdown";
import { SelectedActionsWrap } from "../../../SelectedActionsWrap";
import { ShareRunButton } from "../../../ShareRunButton/ShareRunButton";
import { CreateTemplateModal } from "../CreateTemplateModal/CreateTemplateModal";
import { useSelectedRunActions } from "./useSelectedRunActions";
type Props = {
agent: LibraryAgent;
run: GraphExecution | undefined;
scheduleRecurrence?: string;
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
};
export function SelectedRunActions(props: Props) {
export function SelectedRunActions({
agent,
run,
onSelectRun,
onClearSelectedRun,
}: Props) {
const {
canRunManually,
handleRunAgain,
handleStopRun,
isRunningAgain,
canStop,
isStopping,
openInBuilderHref,
handleCreateTemplate,
isCreateTemplateModalOpen,
setIsCreateTemplateModalOpen,
} = useSelectedRunActions({
agentGraphId: props.agent.graph_id,
run: props.run,
onSelectRun: props.onSelectRun,
onClearSelectedRun: props.onClearSelectedRun,
agentGraphId: agent.graph_id,
run: run,
agent: agent,
onSelectRun: onSelectRun,
});
const shareExecutionResultsEnabled = useGetFlag(Flag.SHARE_EXECUTION_RESULTS);
const isRunning = props.run?.status === "RUNNING";
const isRunning = run?.status === "RUNNING";
if (!props.run || !props.agent) return null;
if (!run || !agent) return null;
return (
<div className="my-4 flex flex-col items-center gap-3">
{!isRunning ? (
<SelectedActionsWrap>
{canRunManually && !isRunning ? (
<Button
variant="icon"
size="icon"
@@ -96,23 +107,38 @@ export function SelectedRunActions(props: Props) {
) : null}
{shareExecutionResultsEnabled && (
<ShareRunButton
graphId={props.agent.graph_id}
executionId={props.run.id}
isShared={props.run.is_shared}
shareToken={props.run.share_token}
graphId={agent.graph_id}
executionId={run.id}
isShared={run.is_shared}
shareToken={run.share_token}
/>
)}
<FloatingSafeModeToggle
graph={props.agent}
variant="white"
fullWidth={false}
/>
<FloatingSafeModeToggle graph={agent} variant="white" fullWidth={false} />
{canRunManually && (
<>
<Button
variant="icon"
size="icon"
aria-label="Save task as template"
onClick={() => setIsCreateTemplateModalOpen(true)}
title="Create template"
>
<CardsThreeIcon weight="bold" size={18} className="text-zinc-700" />
</Button>
<CreateTemplateModal
isOpen={isCreateTemplateModalOpen}
onClose={() => setIsCreateTemplateModalOpen(false)}
onCreate={handleCreateTemplate}
run={run}
/>
</>
)}
<AgentActionsDropdown
agent={props.agent}
run={props.run}
agentGraphId={props.agent.graph_id}
onClearSelectedRun={props.onClearSelectedRun}
agent={agent}
run={run}
agentGraphId={agent.graph_id}
onClearSelectedRun={onClearSelectedRun}
/>
</div>
</SelectedActionsWrap>
);
}

View File

@@ -5,26 +5,39 @@ import {
usePostV1ExecuteGraphAgent,
usePostV1StopGraphExecution,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2ListPresetsQueryKey,
usePostV2CreateANewPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface Args {
interface Params {
agentGraphId: string;
run?: GraphExecution;
agent?: LibraryAgent;
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
}
export function useSelectedRunActions(args: Args) {
export function useSelectedRunActions({
agentGraphId,
run,
agent,
onSelectRun,
}: Params) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isCreateTemplateModalOpen, setIsCreateTemplateModalOpen] =
useState(false);
const canStop =
args.run?.status === "RUNNING" || args.run?.status === "QUEUED";
const canStop = run?.status === "RUNNING" || run?.status === "QUEUED";
const canRunManually = !agent?.trigger_setup_info;
const { mutateAsync: stopRun, isPending: isStopping } =
usePostV1StopGraphExecution();
@@ -32,19 +45,22 @@ export function useSelectedRunActions(args: Args) {
const { mutateAsync: executeRun, isPending: isRunningAgain } =
usePostV1ExecuteGraphAgent();
const { mutateAsync: createPreset, isPending: isCreatingTemplate } =
usePostV2CreateANewPreset();
async function handleStopRun() {
try {
await stopRun({
graphId: args.run?.graph_id ?? "",
graphExecId: args.run?.id ?? "",
graphId: run?.graph_id ?? "",
graphExecId: run?.id ?? "",
});
toast({ title: "Run stopped" });
await queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
args.agentGraphId,
).queryKey,
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
.queryKey,
});
} catch (error: unknown) {
toast({
@@ -59,7 +75,7 @@ export function useSelectedRunActions(args: Args) {
}
async function handleRunAgain() {
if (!args.run) {
if (!run) {
toast({
title: "Run not found",
description: "Run not found",
@@ -72,11 +88,11 @@ export function useSelectedRunActions(args: Args) {
toast({ title: "Run started" });
const res = await executeRun({
graphId: args.run.graph_id,
graphVersion: args.run.graph_version,
graphId: run.graph_id,
graphVersion: run.graph_version,
data: {
inputs: args.run.inputs || {},
credentials_inputs: args.run.credential_inputs || {},
inputs: run.inputs || {},
credentials_inputs: run.credential_inputs || {},
source: "library",
},
});
@@ -84,12 +100,12 @@ export function useSelectedRunActions(args: Args) {
const newRunId = res?.status === 200 ? (res?.data?.id ?? "") : "";
await queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
args.agentGraphId,
).queryKey,
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
.queryKey,
});
if (newRunId && args.onSelectRun) args.onSelectRun(newRunId);
if (newRunId && onSelectRun) onSelectRun(newRunId);
} catch (error: unknown) {
toast({
title: "Failed to start run",
@@ -106,9 +122,55 @@ export function useSelectedRunActions(args: Args) {
setShowDeleteDialog(open);
}
async function handleCreateTemplate(name: string, description: string) {
if (!run) {
toast({
title: "Run not found",
description: "Cannot create template from missing run",
variant: "destructive",
});
return;
}
try {
const res = await createPreset({
data: {
name,
description,
graph_execution_id: run.id,
},
});
if (res.status === 200) {
toast({
title: "Template created",
});
if (agent) {
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
}),
});
}
setIsCreateTemplateModalOpen(false);
}
} catch (error: unknown) {
toast({
title: "Failed to create template",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
// Open in builder URL helper
const openInBuilderHref = args.run
? `/build?flowID=${args.run.graph_id}&flowVersion=${args.run.graph_version}&flowExecutionID=${args.run.id}`
const openInBuilderHref = run
? `/build?flowID=${run.graph_id}&flowVersion=${run.graph_version}&flowExecutionID=${run.id}`
: undefined;
return {
@@ -116,9 +178,14 @@ export function useSelectedRunActions(args: Args) {
showDeleteDialog,
canStop,
isStopping,
canRunManually,
isRunningAgain,
handleShowDeleteDialog,
handleStopRun,
handleRunAgain,
handleCreateTemplate,
isCreatingTemplate,
isCreateTemplateModalOpen,
setIsCreateTemplateModalOpen,
} as const;
}

View File

@@ -0,0 +1,92 @@
"use client";
import { GraphTriggerInfo } from "@/app/api/__generated__/models/graphTriggerInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CopyIcon } from "@phosphor-icons/react";
import { RunDetailCard } from "../../RunDetailCard/RunDetailCard";
interface Props {
preset: LibraryAgentPreset;
triggerSetupInfo: GraphTriggerInfo;
}
function getTriggerStatus(
preset: LibraryAgentPreset,
): "active" | "inactive" | "broken" {
if (!preset.webhook_id || !preset.webhook) return "broken";
return preset.is_active ? "active" : "inactive";
}
export function WebhookTriggerSection({ preset, triggerSetupInfo }: Props) {
const status = getTriggerStatus(preset);
const webhook = preset.webhook;
function handleCopyWebhookUrl() {
if (webhook?.url) {
navigator.clipboard.writeText(webhook.url);
}
}
return (
<RunDetailCard title="Trigger Status">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Text variant="large-medium">Status</Text>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === "active"
? "bg-green-100 text-green-800"
: status === "inactive"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{status === "active"
? "Active"
: status === "inactive"
? "Inactive"
: "Broken"}
</span>
</div>
{!preset.webhook_id ? (
<Text variant="body" className="text-red-600">
This trigger is not attached to a webhook. Use &quot;Set up
trigger&quot; to fix this.
</Text>
) : !triggerSetupInfo.credentials_input_name && webhook ? (
<div className="flex flex-col gap-2">
<Text variant="body">
This trigger is ready to be used. Use the Webhook URL below to set
up the trigger connection with the service of your choosing.
</Text>
<div className="flex flex-col gap-1">
<Text variant="small-medium">Webhook URL:</Text>
<div className="flex gap-2 rounded-md bg-gray-50 p-2">
<code className="flex-1 select-all text-sm">{webhook.url}</code>
<Button
variant="outline"
size="icon"
className="size-7 flex-none p-1"
onClick={handleCopyWebhookUrl}
title="Copy webhook URL"
>
<CopyIcon className="size-4" />
</Button>
</div>
</div>
</div>
) : (
<Text variant="body" className="text-muted-foreground">
This agent trigger is{" "}
{preset.is_active
? "ready. When a trigger is received, it will run with the provided settings."
: "disabled. It will not respond to triggers until you enable it."}
</Text>
)}
</div>
</RunDetailCard>
);
}

View File

@@ -1,8 +1,11 @@
"use client";
import { useGetV1GetExecutionDetails } from "@/app/api/__generated__/endpoints/graphs/graphs";
import type { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
import { useGetV2GetASpecificPreset } from "@/app/api/__generated__/endpoints/presets/presets";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import type { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { okData } from "@/app/api/helpers";
export function useSelectedRunView(graphId: string, runId: string) {
const query = useGetV1GetExecutionDetails(graphId, runId, {
@@ -37,6 +40,18 @@ export function useSelectedRunView(graphId: string, runId: string) {
? (query.data?.data as GetV1GetExecutionDetails200)
: undefined;
const presetId =
run && "preset_id" in run && run.preset_id
? (run.preset_id as string)
: undefined;
const presetQuery = useGetV2GetASpecificPreset(presetId || "", {
query: {
enabled: !!presetId,
select: (res) => okData<LibraryAgentPreset>(res),
},
});
const httpError =
status && status !== 200
? { status, statusText: `Request failed: ${status}` }
@@ -44,8 +59,9 @@ export function useSelectedRunView(graphId: string, runId: string) {
return {
run,
isLoading: query.isLoading,
responseError: query.error,
preset: presetQuery.data,
isLoading: query.isLoading || presetQuery.isLoading,
responseError: query.error || presetQuery.error,
httpError,
} as const;
}

View File

@@ -6,9 +6,10 @@ import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner
import { Text } from "@/components/atoms/Text/Text";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -41,6 +42,9 @@ export function SelectedScheduleView({
},
});
const breakpoint = useBreakpoint();
const isLgScreenUp = isLargeScreen(breakpoint);
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (element) {
@@ -83,39 +87,42 @@ export function SelectedScheduleView({
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<div className="flex flex-col gap-4">
<div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader
<div className="flex w-full flex-col gap-0">
<RunDetailHeader
agent={agent}
run={undefined}
scheduleRecurrence={
schedule
? `${humanizeCronExpression(schedule.cron || "")} · ${getTimezoneDisplayName(schedule.timezone || userTzRes || "UTC")}`
: undefined
}
/>
{schedule && !isLgScreenUp ? (
<div className="mt-4">
<SelectedScheduleActions
agent={agent}
run={undefined}
scheduleRecurrence={
schedule
? `${humanizeCronExpression(schedule.cron || "")} · ${getTimezoneDisplayName(schedule.timezone || userTzRes || "UTC")}`
: undefined
}
scheduleId={schedule.id}
onDeleted={onClearSelectedRun}
/>
</div>
</div>
) : null}
</div>
{/* Navigation Links */}
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
<nav className="flex gap-8 px-3 pb-1">
<button
onClick={() => scrollToSection("schedule")}
className={anchorStyles}
>
Schedule
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
</nav>
</div>
<AnchorLinksWrap>
<button
onClick={() => scrollToSection("schedule")}
className={anchorStyles}
>
Schedule
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
</AnchorLinksWrap>
{/* Schedule Section */}
<div id="schedule" className="scroll-mt-4">
@@ -174,10 +181,6 @@ export function SelectedScheduleView({
<div id="input" className="scroll-mt-4">
<RunDetailCard title="Your input">
<div className="relative">
{/* {// TODO: re-enable edit inputs modal once the API supports it */}
{/* {schedule && Object.keys(schedule.input_data).length > 0 && (
<EditInputsModal agent={agent} schedule={schedule} />
)} */}
<AgentInputsReadOnly
agent={agent}
inputs={schedule?.input_data}
@@ -189,8 +192,8 @@ export function SelectedScheduleView({
</div>
</SelectedViewLayout>
</div>
{schedule ? (
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
{schedule && isLgScreenUp ? (
<div className="max-w-[3.75rem] flex-shrink-0">
<SelectedScheduleActions
agent={agent}
scheduleId={schedule.id}

View File

@@ -3,6 +3,7 @@ import { Button } from "@/components/atoms/Button/Button";
import { EyeIcon } from "@phosphor-icons/react";
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
import { useScheduleDetailHeader } from "../../RunDetailHeader/useScheduleDetailHeader";
import { SelectedActionsWrap } from "../../SelectedActionsWrap";
type Props = {
agent: LibraryAgent;
@@ -19,7 +20,7 @@ export function SelectedScheduleActions({ agent, scheduleId }: Props) {
return (
<>
<div className="my-4 flex flex-col items-center gap-3">
<SelectedActionsWrap>
{openInBuilderHref && (
<Button
variant="icon"
@@ -32,7 +33,7 @@ export function SelectedScheduleActions({ agent, scheduleId }: Props) {
</Button>
)}
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
</div>
</SelectedActionsWrap>
</>
);
}

View File

@@ -1,174 +1,65 @@
"use client";
import React, { useCallback, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
PencilIcon,
PlayIcon,
StopIcon,
TrashIcon,
} from "@phosphor-icons/react";
import {
TabsLine,
TabsLineContent,
TabsLineList,
TabsLineTrigger,
} from "@/components/molecules/TabsLine/TabsLine";
import { useToast } from "@/components/molecules/Toast/use-toast";
import {
useDeleteV2DeleteAPreset,
getGetV2ListPresetsQueryKey,
useGetV2GetASpecificPreset,
getGetV2GetASpecificPresetQueryKey,
usePatchV2UpdateAnExistingPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import { getGetV1ListGraphExecutionsQueryKey } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
getAgentCredentialsFields,
getAgentInputFields,
} from "../../modals/AgentInputsReadOnly/helpers";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { RunAgentModal } from "../../modals/RunAgentModal/RunAgentModal";
import { okData } from "@/app/api/helpers";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SelectedTemplateActions } from "./components/SelectedTemplateActions";
import { WebhookTriggerCard } from "./components/WebhookTriggerCard";
import { useSelectedTemplateView } from "./useSelectedTemplateView";
interface SelectedTemplateViewProps {
interface Props {
agent: LibraryAgent;
presetID: string;
onDelete?: (presetID: string) => void;
onCreateRun?: (runId: string) => void;
onCreateSchedule?: (scheduleId: string) => void;
templateId: string;
onClearSelectedRun?: () => void;
onRunCreated?: (execution: GraphExecutionMeta) => void;
onSwitchToRunsTab?: () => void;
}
export function SelectedTemplateView({
agent,
presetID,
onDelete,
onCreateRun: _onCreateRun,
onCreateSchedule: _onCreateSchedule,
}: SelectedTemplateViewProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isDeleting, setIsDeleting] = useState(false);
const templateOrTrigger = agent.trigger_setup_info ? "Trigger" : "Template";
const presetQuery = useGetV2GetASpecificPreset(presetID, {
query: {
enabled: !!agent.graph_id && !!presetID,
// select: okData,
},
});
const preset = useMemo(() => okData(presetQuery.data), [presetQuery.data]);
// Delete preset mutation
const deleteTemplateMutation = useDeleteV2DeleteAPreset({
mutation: {
onSuccess: () => {
toast({
title: `${templateOrTrigger} deleted successfully`,
variant: "default",
});
// Invalidate presets list
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
setIsDeleting(false);
},
onError: (error) => {
toast({
title: `Failed to delete ${templateOrTrigger.toLowerCase()}`,
description: String(error),
variant: "destructive",
});
setIsDeleting(false);
},
},
templateId,
onClearSelectedRun,
onRunCreated,
onSwitchToRunsTab,
}: Props) {
const {
template,
isLoading,
error,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
handleStartTask,
isSaving,
isStarting,
} = useSelectedTemplateView({
templateId,
graphId: agent.graph_id,
onRunCreated,
});
const doDeleteTemplate = async () => {
setIsDeleting(true);
deleteTemplateMutation.mutate({ presetId: presetID });
};
// Toggle trigger active status mutation
const toggleTriggerStatusMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: `Trigger ${preset?.is_active ? "disabled" : "enabled"} successfully`,
variant: "default",
});
// Invalidate preset queries to refresh data
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(presetID),
});
}
},
onError: (error) => {
toast({
title: `Failed to ${preset?.is_active ? "disable" : "enable"} trigger`,
description: String(error),
variant: "destructive",
});
},
},
});
const doToggleTriggerStatus = () => {
if (!preset) return;
toggleTriggerStatusMutation.mutate({
presetId: presetID,
data: {
is_active: !preset.is_active,
},
});
};
const onSave = useCallback(() => {
// Invalidate preset queries to refresh data
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(presetID),
});
}, [queryClient, agent.graph_id, presetID]);
const onCreateRun = useCallback(
(execution: GraphExecutionMeta) => {
// Invalidate runs list
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsQueryKey(agent.graph_id),
});
_onCreateRun?.(execution.id);
},
[queryClient, agent.graph_id, _onCreateRun],
);
const onCreateSchedule = useCallback(
(schedule: GraphExecutionJobInfo) => {
// Invalidate schedules list
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
agent.graph_id,
),
});
_onCreateSchedule?.(schedule.id);
},
[queryClient, agent.graph_id, _onCreateSchedule],
);
const isLoading = presetQuery.isLoading;
const error = presetQuery.error;
const agentInputFields = getAgentInputFields(agent);
const agentCredentialsFields = getAgentCredentialsFields(agent);
const inputFields = Object.entries(agentInputFields);
const credentialFields = Object.entries(agentCredentialsFields);
if (error) {
return (
@@ -178,7 +69,7 @@ export function SelectedTemplateView({
? {
message: String(
(error as unknown as { message?: string })?.message ||
`Failed to load ${templateOrTrigger.toLowerCase()}`,
"Failed to load template",
),
}
: undefined
@@ -196,184 +87,119 @@ export function SelectedTemplateView({
);
}
if (isLoading && !preset) {
return (
<div className="flex-1 space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
if (isLoading && !template) {
return <LoadingSelectedContent agentName={agent.name} agentId={agent.id} />;
}
if (!template) {
return null;
}
const templateOrTrigger = agent.trigger_setup_info ? "Trigger" : "Template";
const hasWebhook = !!template.webhook_id && template.webhook;
return (
<div className="flex flex-col gap-6">
<div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-0">
<div className="flex flex-col gap-2">
<Text variant="h2" className="!text-2xl font-bold">
{preset?.name || "Loading..."}
</Text>
{/* <Text variant="body-medium" className="!text-zinc-500">
{templateOrTrigger} • {agent.name}
</Text> */}
</div>
</div>
{preset ? (
<div className="flex gap-2">
{!agent.has_external_trigger ? (
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="small"
leftIcon={<PlayIcon size={16} />}
>
Run {templateOrTrigger}
</Button>
}
agent={agent}
initialInputValues={preset.inputs || {}}
initialInputCredentials={preset.credentials || {}}
initialPresetName={preset.name}
initialPresetDescription={preset.description}
onRunCreated={onCreateRun}
onScheduleCreated={onCreateSchedule}
/>
) : null}
<RunAgentModal
triggerSlot={
<Button
variant="secondary"
size="small"
leftIcon={<PencilIcon size={16} />}
>
Edit
</Button>
}
agent={agent}
editMode={{
preset,
onSaved: onSave,
}}
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={undefined} />
{hasWebhook && agent.trigger_setup_info && (
<WebhookTriggerCard
template={template}
triggerSetupInfo={agent.trigger_setup_info}
/>
{/* Enable/Disable Trigger Button - only for triggered presets */}
{preset.webhook && (
<Button
variant={preset.is_active ? "destructive" : "primary"}
size="small"
onClick={doToggleTriggerStatus}
disabled={toggleTriggerStatusMutation.isPending}
leftIcon={
preset.is_active ? (
<StopIcon size={16} />
) : (
<PlayIcon size={16} />
)
}
>
{toggleTriggerStatusMutation.isPending
? preset.is_active
? "Disabling..."
: "Enabling..."
: preset.is_active
? "Disable Trigger"
: "Enable Trigger"}
</Button>
)}
<Button
// TODO: add confirmation modal before deleting
variant="destructive"
size="small"
onClick={() => {
doDeleteTemplate();
onDelete?.(presetID);
}}
disabled={isDeleting}
leftIcon={<TrashIcon size={16} />}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
) : null}
</div>
</div>
<TabsLine defaultValue="input">
<TabsLineList>
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
<TabsLineTrigger value="details">
{templateOrTrigger} details
</TabsLineTrigger>
</TabsLineList>
<TabsLineContent value="input">
<RunDetailCard>
<div className="relative">
<AgentInputsReadOnly
agent={agent}
inputs={preset?.inputs}
credentialInputs={preset?.credentials}
/>
</div>
</RunDetailCard>
</TabsLineContent>
<TabsLineContent value="details">
<RunDetailCard>
{isLoading || !preset ? (
<div className="text-neutral-500">Loading</div>
) : (
<div className="relative flex flex-col gap-8">
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Name
</Text>
<p className="text-sm text-zinc-600">{preset.name}</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Description
</Text>
<p className="text-sm text-zinc-600">
{preset.description || "No description provided"}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Created
</Text>
<p className="text-sm text-zinc-600">
{new Date(preset.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Last Updated
</Text>
<p className="text-sm text-zinc-600">
{new Date(preset.updated_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
)}
</RunDetailCard>
</TabsLineContent>
</TabsLine>
<RunDetailCard title={`${templateOrTrigger} Details`}>
<div className="flex flex-col gap-2">
<Input
id="template-name"
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`Enter ${templateOrTrigger.toLowerCase()} name`}
/>
<Input
id="template-description"
label="Description"
type="textarea"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={`Enter ${templateOrTrigger.toLowerCase()} description`}
/>
</div>
</RunDetailCard>
{inputFields.length > 0 && (
<RunDetailCard title="Your Input">
<div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => (
<div
key={key}
className="flex w-full flex-col gap-0 space-y-2"
>
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
{inputSubSchema.description && (
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
))}
</div>
</RunDetailCard>
)}
{credentialFields.length > 0 && (
<RunDetailCard title="Task Credentials">
<div className="flex flex-col gap-6">
{credentialFields.map(([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={
credentials[key] ?? inputSubSchema.default
}
onSelectCredentials={(value) =>
setCredentialValue(key, value!)
}
siblingInputs={inputs}
/>
))}
</div>
</RunDetailCard>
)}
</div>
</SelectedViewLayout>
</div>
{template ? (
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
<SelectedTemplateActions
agent={agent}
templateId={template.id}
onDeleted={onClearSelectedRun}
onSaveChanges={handleSaveChanges}
onStartTask={handleStartTask}
isSaving={isSaving}
isStarting={isStarting}
onSwitchToRunsTab={onSwitchToRunsTab}
/>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import {
getGetV2ListPresetsQueryKey,
useDeleteV2DeleteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { LibraryAgentPresetResponse } from "@/app/api/__generated__/models/libraryAgentPresetResponse";
import { okData } from "@/app/api/helpers";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { FloppyDiskIcon, PlayIcon, TrashIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
interface Props {
agent: LibraryAgent;
templateId: string;
onDeleted?: () => void;
onSaveChanges?: () => void;
onStartTask?: () => void;
isSaving?: boolean;
isStarting?: boolean;
onSwitchToRunsTab?: () => void;
}
export function SelectedTemplateActions({
agent,
templateId,
onDeleted,
onSaveChanges,
onStartTask,
isSaving,
isStarting,
onSwitchToRunsTab,
}: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteMutation = useDeleteV2DeleteAPreset({
mutation: {
onSuccess: async () => {
toast({
title: "Template deleted",
});
const queryKey = getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
});
queryClient.invalidateQueries({
queryKey,
});
const queryData = queryClient.getQueryData<{
data: LibraryAgentPresetResponse;
}>(queryKey);
const presets =
okData<LibraryAgentPresetResponse>(queryData)?.presets ?? [];
const templates = presets.filter(
(preset) => !preset.webhook_id || !preset.webhook,
);
setShowDeleteDialog(false);
onDeleted?.();
if (templates.length === 0 && onSwitchToRunsTab) {
onSwitchToRunsTab();
}
},
onError: (error: any) => {
toast({
title: "Failed to delete template",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleDelete() {
deleteMutation.mutate({ presetId: templateId });
}
return (
<>
<div className="my-4 flex flex-col items-center gap-3">
<Button
variant="icon"
size="icon"
aria-label="Save changes"
onClick={onSaveChanges}
disabled={isSaving || isStarting || deleteMutation.isPending}
>
{isSaving ? (
<LoadingSpinner size="small" />
) : (
<FloppyDiskIcon weight="bold" size={18} className="text-zinc-700" />
)}
</Button>
{onStartTask && (
<Button
variant="icon"
size="icon"
aria-label="Start task from template"
onClick={onStartTask}
disabled={isSaving || isStarting || deleteMutation.isPending}
>
{isStarting ? (
<>
<LoadingSpinner size="small" />
</>
) : (
<>
<PlayIcon weight="bold" size={16} />
</>
)}
</Button>
)}
<Button
variant="icon"
size="icon"
aria-label="Delete template"
onClick={() => setShowDeleteDialog(true)}
disabled={isSaving || isStarting || deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
<TrashIcon weight="bold" size={18} />
)}
</Button>
<AgentActionsDropdown agent={agent} />
</div>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete template"
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete this template? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
onClick={() => setShowDeleteDialog(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={deleteMutation.isPending}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,75 +0,0 @@
"use client";
import React from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
PlayIcon,
PencilIcon,
TrashIcon,
CalendarIcon,
} from "@phosphor-icons/react";
interface Props {
onEdit?: () => void;
onDelete?: () => void;
onRun?: () => void;
onCreateSchedule?: () => void;
isRunning?: boolean;
isDeleting?: boolean;
}
export function TemplateActions({
onEdit,
onDelete,
onRun,
onCreateSchedule,
isRunning = false,
isDeleting = false,
}: Props) {
return (
<div className="flex gap-2">
{onRun && (
<Button
variant="primary"
size="small"
onClick={onRun}
disabled={isRunning || isDeleting}
leftIcon={<PlayIcon size={16} />}
>
{isRunning ? "Running..." : "Run Template"}
</Button>
)}
{onEdit && (
<Button
variant="secondary"
size="small"
onClick={onEdit}
leftIcon={<PencilIcon size={16} />}
>
Edit
</Button>
)}
{onCreateSchedule && (
<Button
variant="secondary"
size="small"
onClick={onCreateSchedule}
leftIcon={<CalendarIcon size={16} />}
>
Schedule
</Button>
)}
{onDelete && (
<Button
variant="destructive"
size="small"
onClick={onDelete}
disabled={isRunning || isDeleting}
leftIcon={<TrashIcon size={16} />}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { GraphTriggerInfo } from "@/app/api/__generated__/models/graphTriggerInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CopyIcon } from "@phosphor-icons/react";
import { RunDetailCard } from "../../RunDetailCard/RunDetailCard";
interface Props {
template: LibraryAgentPreset;
triggerSetupInfo: GraphTriggerInfo;
}
function getTriggerStatus(
template: LibraryAgentPreset,
): "active" | "inactive" | "broken" {
if (!template.webhook_id || !template.webhook) return "broken";
return template.is_active ? "active" : "inactive";
}
export function WebhookTriggerCard({ template, triggerSetupInfo }: Props) {
const status = getTriggerStatus(template);
const webhook = template.webhook;
function handleCopyWebhookUrl() {
if (webhook?.url) {
navigator.clipboard.writeText(webhook.url);
}
}
return (
<RunDetailCard title="Trigger Status">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Text variant="large-medium">Status</Text>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === "active"
? "bg-green-100 text-green-800"
: status === "inactive"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{status === "active"
? "Active"
: status === "inactive"
? "Inactive"
: "Broken"}
</span>
</div>
{!template.webhook_id ? (
<Text variant="body" className="text-red-600">
This trigger is not attached to a webhook. Use &quot;Set up
trigger&quot; to fix this.
</Text>
) : !triggerSetupInfo.credentials_input_name && webhook ? (
<div className="flex flex-col gap-2">
<Text variant="body">
This trigger is ready to be used. Use the Webhook URL below to set
up the trigger connection with the service of your choosing.
</Text>
<div className="flex flex-col gap-1">
<Text variant="body-medium">Webhook URL:</Text>
<div className="flex gap-2 rounded-md bg-gray-50 p-2">
<code className="flex-1 select-all text-sm">{webhook.url}</code>
<Button
variant="outline"
size="icon"
className="size-7 flex-none p-1"
onClick={handleCopyWebhookUrl}
title="Copy webhook URL"
>
<CopyIcon className="size-4" />
</Button>
</div>
</div>
</div>
) : (
<Text variant="body" className="text-muted-foreground">
This agent trigger is{" "}
{template.is_active
? "ready. When a trigger is received, it will run with the provided settings."
: "disabled. It will not respond to triggers until you enable it."}
</Text>
)}
</div>
</RunDetailCard>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { getGetV1ListGraphExecutionsInfiniteQueryOptions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2GetASpecificPresetQueryKey,
getGetV2ListPresetsQueryKey,
useGetV2GetASpecificPreset,
usePatchV2UpdateAnExistingPreset,
usePostV2ExecuteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import type { LibraryAgentPresetUpdatable } from "@/app/api/__generated__/models/libraryAgentPresetUpdatable";
import { okData } from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
type Args = {
templateId: string;
graphId: string;
onRunCreated?: (execution: GraphExecutionMeta) => void;
};
export function useSelectedTemplateView({
templateId,
graphId,
onRunCreated,
}: Args) {
const { toast } = useToast();
const queryClient = useQueryClient();
const query = useGetV2GetASpecificPreset(templateId, {
query: {
enabled: !!templateId,
select: (res) => okData<LibraryAgentPreset>(res),
},
});
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [inputs, setInputs] = useState<Record<string, any>>({});
const [credentials, setCredentials] = useState<
Record<string, CredentialsMetaInput>
>({});
useEffect(() => {
if (query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [query.data]);
const updateMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Template updated",
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(templateId),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: graphId }),
});
}
},
onError: (error: any) => {
toast({
title: "Failed to update template",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const executeMutation = usePostV2ExecuteAPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
const execution = okData<GraphExecutionMeta>(response);
if (execution) {
toast({
title: "Task started",
});
queryClient.invalidateQueries({
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(graphId)
.queryKey,
});
onRunCreated?.(execution);
}
}
},
onError: (error: any) => {
toast({
title: "Failed to start task",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleSaveChanges() {
if (!query.data) return;
const updateData: LibraryAgentPresetUpdatable = {};
if (name !== (query.data.name || "")) {
updateData.name = name;
}
if (description !== (query.data.description || "")) {
updateData.description = description;
}
const inputsChanged =
JSON.stringify(inputs) !== JSON.stringify(query.data.inputs || {});
const credentialsChanged =
JSON.stringify(credentials) !==
JSON.stringify(query.data.credentials || {});
if (inputsChanged || credentialsChanged) {
updateData.inputs = inputs;
updateData.credentials = credentials;
}
updateMutation.mutate({
presetId: templateId,
data: updateData,
});
}
function handleStartTask() {
if (!query.data) return;
const inputsChanged =
JSON.stringify(inputs) !== JSON.stringify(query.data.inputs || {});
const credentialsChanged =
JSON.stringify(credentials) !==
JSON.stringify(query.data.credentials || {});
// Use changed unpersisted inputs if applicable
executeMutation.mutate({
presetId: templateId,
data: {
inputs: inputsChanged ? inputs : undefined,
credential_inputs: credentialsChanged ? credentials : undefined,
},
});
}
function setInputValue(key: string, value: any) {
setInputs((prev) => ({ ...prev, [key]: value }));
}
function setCredentialValue(key: string, value: CredentialsMetaInput) {
setCredentials((prev) => ({ ...prev, [key]: value }));
}
const httpError =
query.isSuccess && !query.data
? { status: 404, statusText: "Not found" }
: undefined;
useEffect(() => {
if (updateMutation.isSuccess && query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [updateMutation.isSuccess, query.data]);
return {
template: query.data,
isLoading: query.isLoading,
error: query.error || httpError,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
handleStartTask,
isSaving: updateMutation.isPending,
isStarting: executeMutation.isPending,
} as const;
}

View File

@@ -0,0 +1,196 @@
"use client";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
getAgentCredentialsFields,
getAgentInputFields,
} from "../../modals/AgentInputsReadOnly/helpers";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
import { WebhookTriggerCard } from "../SelectedTemplateView/components/WebhookTriggerCard";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SelectedTriggerActions } from "./components/SelectedTriggerActions";
import { useSelectedTriggerView } from "./useSelectedTriggerView";
interface Props {
agent: LibraryAgent;
triggerId: string;
onClearSelectedRun?: () => void;
onSwitchToRunsTab?: () => void;
}
export function SelectedTriggerView({
agent,
triggerId,
onClearSelectedRun,
onSwitchToRunsTab,
}: Props) {
const {
trigger,
isLoading,
error,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
isSaving,
} = useSelectedTriggerView({
triggerId,
graphId: agent.graph_id,
});
const agentInputFields = getAgentInputFields(agent);
const agentCredentialsFields = getAgentCredentialsFields(agent);
const inputFields = Object.entries(agentInputFields);
const credentialFields = Object.entries(agentCredentialsFields);
if (error) {
return (
<ErrorCard
responseError={
error
? {
message: String(
(error as unknown as { message?: string })?.message ||
"Failed to load trigger",
),
}
: undefined
}
httpError={
(error as any)?.status
? {
status: (error as any).status,
statusText: (error as any).statusText,
}
: undefined
}
context="trigger"
/>
);
}
if (isLoading && !trigger) {
return <LoadingSelectedContent agentName={agent.name} agentId={agent.id} />;
}
if (!trigger) {
return null;
}
const hasWebhook = !!trigger.webhook_id && trigger.webhook;
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={undefined} />
<RunDetailCard title="Trigger Details">
<div className="flex flex-col gap-2">
<Input
id="trigger-name"
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter trigger name"
/>
<Input
id="trigger-description"
label="Description"
type="textarea"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter trigger description"
/>
</div>
</RunDetailCard>
{hasWebhook && agent.trigger_setup_info && (
<WebhookTriggerCard
template={trigger}
triggerSetupInfo={agent.trigger_setup_info}
/>
)}
{inputFields.length > 0 && (
<RunDetailCard title="Your Input">
<div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => (
<div
key={key}
className="flex w-full flex-col gap-0 space-y-2"
>
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
{inputSubSchema.description && (
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
))}
</div>
</RunDetailCard>
)}
{credentialFields.length > 0 && (
<RunDetailCard title="Task Credentials">
<div className="flex flex-col gap-6">
{credentialFields.map(([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={
credentials[key] ?? inputSubSchema.default
}
onSelectCredentials={(value) =>
setCredentialValue(key, value!)
}
siblingInputs={inputs}
/>
))}
</div>
</RunDetailCard>
)}
</div>
</SelectedViewLayout>
</div>
{trigger ? (
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
<SelectedTriggerActions
agent={agent}
triggerId={trigger.id}
onDeleted={onClearSelectedRun}
onSaveChanges={handleSaveChanges}
isSaving={isSaving}
onSwitchToRunsTab={onSwitchToRunsTab}
/>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import {
getGetV2ListPresetsQueryKey,
useDeleteV2DeleteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { LibraryAgentPresetResponse } from "@/app/api/__generated__/models/libraryAgentPresetResponse";
import { okData } from "@/app/api/helpers";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { FloppyDiskIcon, TrashIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
interface Props {
agent: LibraryAgent;
triggerId: string;
onDeleted?: () => void;
onSaveChanges?: () => void;
isSaving?: boolean;
onSwitchToRunsTab?: () => void;
}
export function SelectedTriggerActions({
agent,
triggerId,
onDeleted,
onSaveChanges,
isSaving,
onSwitchToRunsTab,
}: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteMutation = useDeleteV2DeleteAPreset({
mutation: {
onSuccess: async () => {
toast({
title: "Trigger deleted",
});
const queryKey = getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
});
queryClient.invalidateQueries({
queryKey,
});
const queryData = queryClient.getQueryData<{
data: LibraryAgentPresetResponse;
}>(queryKey);
const presets =
okData<LibraryAgentPresetResponse>(queryData)?.presets ?? [];
const triggers = presets.filter(
(preset) => preset.webhook_id && preset.webhook,
);
setShowDeleteDialog(false);
onDeleted?.();
if (triggers.length === 0 && onSwitchToRunsTab) {
onSwitchToRunsTab();
}
},
onError: (error: any) => {
toast({
title: "Failed to delete trigger",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleDelete() {
deleteMutation.mutate({ presetId: triggerId });
}
return (
<>
<div className="my-4 flex flex-col items-center gap-3">
<Button
variant="icon"
size="icon"
aria-label="Save changes"
onClick={onSaveChanges}
disabled={isSaving || deleteMutation.isPending}
>
{isSaving ? (
<LoadingSpinner size="small" />
) : (
<FloppyDiskIcon weight="bold" size={18} className="text-zinc-700" />
)}
</Button>
<Button
variant="icon"
size="icon"
aria-label="Delete trigger"
onClick={() => setShowDeleteDialog(true)}
disabled={isSaving || deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
<TrashIcon weight="bold" size={18} />
)}
</Button>
<AgentActionsDropdown agent={agent} />
</div>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete trigger"
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete this trigger? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
onClick={() => setShowDeleteDialog(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={deleteMutation.isPending}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import {
getGetV2GetASpecificPresetQueryKey,
getGetV2ListPresetsQueryKey,
useGetV2GetASpecificPreset,
usePatchV2UpdateAnExistingPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import type { LibraryAgentPresetUpdatable } from "@/app/api/__generated__/models/libraryAgentPresetUpdatable";
import { okData } from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
type Args = {
triggerId: string;
graphId: string;
};
export function useSelectedTriggerView({ triggerId, graphId }: Args) {
const { toast } = useToast();
const queryClient = useQueryClient();
const query = useGetV2GetASpecificPreset(triggerId, {
query: {
enabled: !!triggerId,
select: (res) => okData<LibraryAgentPreset>(res),
},
});
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [inputs, setInputs] = useState<Record<string, any>>({});
const [credentials, setCredentials] = useState<
Record<string, CredentialsMetaInput>
>({});
useEffect(() => {
if (query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [query.data]);
const updateMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Trigger updated",
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(triggerId),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: graphId }),
});
}
},
onError: (error: any) => {
toast({
title: "Failed to update trigger",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleSaveChanges() {
if (!query.data) return;
const updateData: LibraryAgentPresetUpdatable = {};
if (name !== (query.data.name || "")) {
updateData.name = name;
}
if (description !== (query.data.description || "")) {
updateData.description = description;
}
const inputsChanged =
JSON.stringify(inputs) !== JSON.stringify(query.data.inputs || {});
const credentialsChanged =
JSON.stringify(credentials) !==
JSON.stringify(query.data.credentials || {});
if (inputsChanged || credentialsChanged) {
updateData.inputs = inputs;
updateData.credentials = credentials;
}
updateMutation.mutate({
presetId: triggerId,
data: updateData,
});
}
function setInputValue(key: string, value: any) {
setInputs((prev) => ({ ...prev, [key]: value }));
}
function setCredentialValue(key: string, value: CredentialsMetaInput) {
setCredentials((prev) => ({ ...prev, [key]: value }));
}
const httpError =
query.isSuccess && !query.data
? { status: 404, statusText: "Not found" }
: undefined;
useEffect(() => {
if (updateMutation.isSuccess && query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [updateMutation.isSuccess, query.data]);
return {
trigger: query.data,
isLoading: query.isLoading,
error: query.error || httpError,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
isSaving: updateMutation.isPending,
} as const;
}

View File

@@ -12,7 +12,7 @@ export function SelectedViewLayout(props: Props) {
return (
<SectionWrap className="relative mb-3 flex min-h-0 flex-1 flex-col">
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-4`}
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
>
<Breadcrumbs
items={[

View File

@@ -14,21 +14,26 @@ import {
} from "@/components/molecules/TabsLine/TabsLine";
import { cn } from "@/lib/utils";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { RunListItem } from "./components/RunListItem";
import { ScheduleListItem } from "./components/ScheduleListItem";
import { TaskListItem } from "./components/TaskListItem";
import { TemplateListItem } from "./components/TemplateListItem";
import { TriggerListItem } from "./components/TriggerListItem";
import { useSidebarRunsList } from "./useSidebarRunsList";
interface Props {
agent: LibraryAgent;
selectedRunId?: string;
onSelectRun: (id: string, tab?: "runs" | "scheduled") => void;
onSelectRun: (
id: string,
tab?: "runs" | "scheduled" | "templates" | "triggers",
) => void;
onClearSelectedRun?: () => void;
onTabChange?: (tab: "runs" | "scheduled" | "templates") => void;
onTabChange?: (tab: "runs" | "scheduled" | "templates" | "triggers") => void;
onCountsChange?: (info: {
runsCount: number;
schedulesCount: number;
presetsCount: number;
templatesCount: number;
triggersCount: number;
loading?: boolean;
}) => void;
}
@@ -44,18 +49,17 @@ export function SidebarRunsList({
const {
runs,
schedules,
presets,
templates,
triggers,
runsCount,
schedulesCount,
presetsCount,
templatesCount,
triggersCount,
error,
loading,
hasMoreRuns,
fetchMoreRuns,
hasMoreRuns,
isFetchingMoreRuns,
hasMorePresets,
fetchMorePresets,
isFetchingMorePresets,
tabValue,
} = useSidebarRunsList({
graphId: agent.graph_id,
@@ -86,7 +90,7 @@ export function SidebarRunsList({
<TabsLine
value={tabValue}
onValueChange={(v) => {
const value = v as "runs" | "scheduled" | "templates";
const value = v as "runs" | "scheduled" | "templates" | "triggers";
onTabChange?.(value);
if (value === "runs") {
if (runs && runs.length) {
@@ -101,27 +105,39 @@ export function SidebarRunsList({
onClearSelectedRun?.();
}
} else if (value === "templates") {
if (presets && presets.length) {
onSelectRun(`preset:${presets[0].id}`);
} else {
onClearSelectedRun?.();
}
onClearSelectedRun?.();
} else if (value === "triggers") {
onClearSelectedRun?.();
}
}}
className="flex min-h-0 flex-col overflow-hidden"
>
<TabsLineList className={AGENT_LIBRARY_SECTION_PADDING_X}>
<TabsLineTrigger value="runs">
Tasks <span className="ml-3 inline-block">{runsCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="scheduled">
Scheduled <span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="templates">
{agent.trigger_setup_info ? "Triggers" : "Templates"}{" "}
<span className="ml-3 inline-block">{presetsCount}</span>
</TabsLineTrigger>
</TabsLineList>
<div className="relative overflow-hidden">
<div className="pointer-events-none absolute right-0 top-0 z-10 h-[46px] w-12 bg-gradient-to-l from-[#FAFAFA] to-transparent" />
<div className="scrollbar-hide overflow-x-auto">
<TabsLineList
className={cn(AGENT_LIBRARY_SECTION_PADDING_X, "min-w-max")}
>
<TabsLineTrigger value="runs">
Tasks <span className="ml-3 inline-block">{runsCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="scheduled">
Scheduled{" "}
<span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
{triggersCount > 0 && (
<TabsLineTrigger value="triggers">
Triggers{" "}
<span className="ml-3 inline-block">{triggersCount}</span>
</TabsLineTrigger>
)}
<TabsLineTrigger value="templates">
Templates{" "}
<span className="ml-3 inline-block">{templatesCount}</span>
</TabsLineTrigger>
</TabsLineList>
</div>
</div>
<>
<TabsLineContent
@@ -140,9 +156,10 @@ export function SidebarRunsList({
itemWrapperClassName="w-auto lg:w-full"
renderItem={(run) => (
<div className="w-[15rem] lg:w-full">
<RunListItem
<TaskListItem
run={run}
title={agent.name}
agent={agent}
selected={selectedRunId === run.id}
onClick={() => onSelectRun && onSelectRun(run.id, "runs")}
/>
@@ -163,6 +180,7 @@ export function SidebarRunsList({
<div className="w-[15rem] lg:w-full" key={s.id}>
<ScheduleListItem
schedule={s}
agent={agent}
selected={selectedRunId === s.id}
onClick={() => onSelectRun(s.id, "scheduled")}
/>
@@ -177,32 +195,63 @@ export function SidebarRunsList({
)}
</div>
</TabsLineContent>
{triggersCount > 0 && (
<TabsLineContent
value="triggers"
className={cn(
"mt-0 flex min-h-0 flex-1 flex-col",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<div className="flex h-full flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 lg:flex-col lg:gap-3 lg:overflow-y-auto lg:overflow-x-hidden">
{triggers.length > 0 ? (
triggers.map((trigger) => (
<div className="w-[15rem] lg:w-full" key={trigger.id}>
<TriggerListItem
trigger={trigger}
agent={agent}
selected={selectedRunId === trigger.id}
onClick={() => onSelectRun(trigger.id, "triggers")}
/>
</div>
))
) : (
<div className="flex min-h-[50vh] flex-col items-center justify-center">
<Text variant="large" className="text-zinc-700">
No triggers set up
</Text>
</div>
)}
</div>
</TabsLineContent>
)}
<TabsLineContent
value="templates"
className={cn(
"flex min-h-0 flex-1 flex-col",
"mt-0 flex min-h-0 flex-1 flex-col",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<InfiniteList
items={presets}
hasMore={!!hasMorePresets}
isFetchingMore={isFetchingMorePresets}
onEndReached={fetchMorePresets}
className="flex max-h-[76vh] flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 lg:flex-col lg:gap-3 lg:overflow-y-auto lg:overflow-x-hidden"
itemWrapperClassName="w-auto lg:w-full"
renderItem={(preset) => (
<div className="w-[15rem] lg:w-full">
<TemplateListItem
preset={preset}
selected={selectedRunId === `preset:${preset.id}`}
onClick={() =>
onSelectRun && onSelectRun(`preset:${preset.id}`)
}
/>
<div className="flex h-full flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 lg:flex-col lg:gap-3 lg:overflow-y-auto lg:overflow-x-hidden">
{templates.length > 0 ? (
templates.map((template) => (
<div className="w-[15rem] lg:w-full" key={template.id}>
<TemplateListItem
template={template}
agent={agent}
selected={selectedRunId === template.id}
onClick={() => onSelectRun(template.id, "templates")}
/>
</div>
))
) : (
<div className="flex min-h-[50vh] flex-col items-center justify-center">
<Text variant="large" className="text-zinc-700">
No templates saved
</Text>
</div>
)}
/>
</div>
</TabsLineContent>
</>
</TabsLine>

View File

@@ -0,0 +1,123 @@
"use client";
import {
getGetV1ListExecutionSchedulesForAGraphQueryOptions,
useDeleteV1DeleteExecutionSchedule,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { DotsThreeVertical } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface Props {
agent: LibraryAgent;
schedule: GraphExecutionJobInfo;
onDeleted?: () => void;
}
export function ScheduleActionsDropdown({ agent, schedule, onDeleted }: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { mutateAsync: deleteSchedule, isPending: isDeleting } =
useDeleteV1DeleteExecutionSchedule();
async function handleDelete() {
try {
await deleteSchedule({ scheduleId: schedule.id });
toast({ title: "Schedule deleted" });
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryOptions(
agent.graph_id,
).queryKey,
});
setShowDeleteDialog(false);
onDeleted?.();
} catch (error: unknown) {
toast({
title: "Failed to delete schedule",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
aria-label="More actions"
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2"
>
Delete schedule
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete schedule"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this schedule? This action cannot
be undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeleting}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
>
Delete Schedule
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,38 +1,50 @@
"use client";
import React from "react";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import moment from "moment";
import { RunSidebarCard } from "./RunSidebarCard";
import { IconWrapper } from "./RunIconWrapper";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { ClockClockwiseIcon } from "@phosphor-icons/react";
import moment from "moment";
import { IconWrapper } from "./IconWrapper";
import { ScheduleActionsDropdown } from "./ScheduleActionsDropdown";
import { SidebarItemCard } from "./SidebarItemCard";
interface ScheduleListItemProps {
interface Props {
schedule: GraphExecutionJobInfo;
agent: LibraryAgent;
selected?: boolean;
onClick?: () => void;
onDeleted?: () => void;
}
export function ScheduleListItem({
schedule,
agent,
selected,
onClick,
}: ScheduleListItemProps) {
onDeleted,
}: Props) {
return (
<RunSidebarCard
<SidebarItemCard
title={schedule.name}
description={moment(schedule.next_run_time).fromNow()}
onClick={onClick}
selected={selected}
icon={
<IconWrapper className="border-slate-50 bg-slate-50">
<IconWrapper className="border-slate-50 bg-yellow-50">
<ClockClockwiseIcon
size={16}
className="text-slate-700"
className="text-yellow-700"
weight="bold"
/>
</IconWrapper>
}
actions={
<ScheduleActionsDropdown
agent={agent}
schedule={schedule}
onDeleted={onDeleted}
/>
}
/>
);
}

View File

@@ -4,27 +4,27 @@ import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import React from "react";
interface RunListItemProps {
interface Props {
title: string;
description?: string;
icon?: React.ReactNode;
statusBadge?: React.ReactNode;
selected?: boolean;
onClick?: () => void;
actions?: React.ReactNode;
}
export function RunSidebarCard({
export function SidebarItemCard({
title,
description,
icon,
statusBadge,
selected,
onClick,
}: RunListItemProps) {
actions,
}: Props) {
return (
<button
<div
className={cn(
"w-full rounded-large border border-zinc-200 bg-white p-3 text-left ring-1 ring-transparent transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
"w-full cursor-pointer rounded-large border border-zinc-200 bg-white p-3 text-left ring-1 ring-transparent transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
selected ? "border-slate-800 ring-slate-800" : undefined,
)}
onClick={onClick}
@@ -32,20 +32,20 @@ export function RunSidebarCard({
<div className="flex min-w-0 items-center justify-start gap-3">
{icon}
<div className="flex min-w-0 flex-1 flex-col items-start justify-between gap-0">
<div className="flex w-full items-center justify-between">
<Text
variant="body-medium"
className="block truncate text-ellipsis"
>
{title}
</Text>
{statusBadge}
</div>
<Text
variant="body-medium"
className="block w-full truncate text-ellipsis"
>
{title}
</Text>
<Text variant="body" className="leading-tight !text-zinc-500">
{description}
</Text>
</div>
{actions ? (
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
) : null}
</div>
</button>
</div>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import {
getGetV1ListGraphExecutionsInfiniteQueryOptions,
useDeleteV1DeleteGraphExecution,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2ListPresetsQueryKey,
usePostV2CreateANewPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { DotsThreeVertical } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { CreateTemplateModal } from "../../../selected-views/SelectedRunView/components/CreateTemplateModal/CreateTemplateModal";
interface Props {
agent: LibraryAgent;
run: GraphExecutionMeta;
onDeleted?: () => void;
}
export function TaskActionsDropdown({ agent, run, onDeleted }: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isCreateTemplateModalOpen, setIsCreateTemplateModalOpen] =
useState(false);
const { mutateAsync: deleteRun, isPending: isDeletingRun } =
useDeleteV1DeleteGraphExecution();
const { mutateAsync: createPreset } = usePostV2CreateANewPreset();
async function handleDeleteRun() {
try {
await deleteRun({ graphExecId: run.id });
toast({ title: "Task deleted" });
await queryClient.refetchQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
agent.graph_id,
).queryKey,
});
setShowDeleteDialog(false);
onDeleted?.();
} catch (error: unknown) {
toast({
title: "Failed to delete task",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
async function handleCreateTemplate(name: string, description: string) {
try {
const res = await createPreset({
data: {
name,
description,
graph_execution_id: run.id,
},
});
if (res.status === 200) {
toast({
title: "Template created",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
}),
});
setIsCreateTemplateModalOpen(false);
}
} catch (error: unknown) {
toast({
title: "Failed to create template",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
aria-label="More actions"
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setIsCreateTemplateModalOpen(true);
}}
className="flex items-center gap-2"
>
Save as template
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2"
>
Delete task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete task"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this task? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeletingRun}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteRun}
loading={isDeletingRun}
>
Delete Task
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
<CreateTemplateModal
isOpen={isCreateTemplateModalOpen}
onClose={() => setIsCreateTemplateModalOpen(false)}
onCreate={handleCreateTemplate}
run={run as any}
/>
</>
);
}

View File

@@ -2,6 +2,7 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import {
CheckCircleIcon,
ClockIcon,
@@ -12,8 +13,9 @@ import {
} from "@phosphor-icons/react";
import moment from "moment";
import React from "react";
import { IconWrapper } from "./RunIconWrapper";
import { RunSidebarCard } from "./RunSidebarCard";
import { IconWrapper } from "./IconWrapper";
import { SidebarItemCard } from "./SidebarItemCard";
import { TaskActionsDropdown } from "./TaskActionsDropdown";
const statusIconMap: Record<AgentExecutionStatus, React.ReactNode> = {
INCOMPLETE: (
@@ -53,26 +55,33 @@ const statusIconMap: Record<AgentExecutionStatus, React.ReactNode> = {
),
};
interface RunListItemProps {
interface Props {
run: GraphExecutionMeta;
title: string;
agent: LibraryAgent;
selected?: boolean;
onClick?: () => void;
onDeleted?: () => void;
}
export function RunListItem({
export function TaskListItem({
run,
title,
agent,
selected,
onClick,
}: RunListItemProps) {
onDeleted,
}: Props) {
return (
<RunSidebarCard
<SidebarItemCard
icon={statusIconMap[run.status]}
title={title}
description={moment(run.started_at).fromNow()}
onClick={onClick}
selected={selected}
actions={
<TaskActionsDropdown agent={agent} run={run} onDeleted={onDeleted} />
}
/>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import {
getGetV2ListPresetsQueryKey,
useDeleteV2DeleteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { DotsThreeVertical } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface Props {
agent: LibraryAgent;
template: LibraryAgentPreset;
onDeleted?: () => void;
}
export function TemplateActionsDropdown({ agent, template, onDeleted }: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { mutateAsync: deletePreset, isPending: isDeleting } =
useDeleteV2DeleteAPreset();
async function handleDelete() {
try {
await deletePreset({ presetId: template.id });
toast({
title: "Template deleted",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
}),
});
setShowDeleteDialog(false);
onDeleted?.();
} catch (error: unknown) {
toast({
title: "Failed to delete template",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
aria-label="More actions"
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2"
>
Delete template
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete template"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this template? This action cannot
be undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeleting}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
>
Delete Template
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,68 +1,45 @@
"use client";
import React from "react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { RunSidebarCard } from "./RunSidebarCard";
import { IconWrapper } from "./RunIconWrapper";
import { LinkIcon, PushPinIcon } from "@phosphor-icons/react";
import { FileTextIcon } from "@phosphor-icons/react";
import moment from "moment";
import { IconWrapper } from "./IconWrapper";
import { SidebarItemCard } from "./SidebarItemCard";
import { TemplateActionsDropdown } from "./TemplateActionsDropdown";
interface TemplateListItemProps {
preset: LibraryAgentPreset;
interface Props {
template: LibraryAgentPreset;
agent: LibraryAgent;
selected?: boolean;
onClick?: () => void;
onDeleted?: () => void;
}
export function TemplateListItem({
preset,
template,
agent,
selected,
onClick,
}: TemplateListItemProps) {
const isTrigger = !!preset.webhook;
const isActive = preset.is_active ?? false;
onDeleted,
}: Props) {
return (
<RunSidebarCard
title={preset.name}
description={preset.description || "No description"}
onClick={onClick}
selected={selected}
<SidebarItemCard
icon={
<IconWrapper
className={
isTrigger
? isActive
? "border-green-50 bg-green-50"
: "border-gray-50 bg-gray-50"
: "border-blue-50 bg-blue-50"
}
>
{isTrigger ? (
<LinkIcon
size={16}
className={isActive ? "text-green-700" : "text-gray-700"}
weight="bold"
/>
) : (
<PushPinIcon size={16} className="text-blue-700" weight="bold" />
)}
<IconWrapper className="border-blue-50 bg-blue-50">
<FileTextIcon size={16} className="text-zinc-700" weight="bold" />
</IconWrapper>
}
statusBadge={
isTrigger ? (
<span
className={`rounded-full px-2 py-0.5 text-xs ${
isActive
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-600"
}`}
>
{isActive ? "Active" : "Inactive"}
</span>
) : (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-800">
Template
</span>
)
title={template.name}
description={moment(template.updated_at).fromNow()}
onClick={onClick}
selected={selected}
actions={
<TemplateActionsDropdown
agent={agent}
template={template}
onDeleted={onDeleted}
/>
}
/>
);

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