Compare commits

...

46 Commits

Author SHA1 Message Date
Waleed Latif
ed9b9ad83f v0.3.39: billing fixes, custom tools fixes, copilot client-side migration, new tools 2025-08-24 00:18:25 -07:00
Waleed Latif
766279bb8b feat(integrations): added parallel AI, mySQL, and postgres block/tools (#1126)
* feat(integrations): added parallel ai block/tool and corresponding docs

* add postgres block

* added mysql block

* enrich docs for Postgres and MySQL

* make password fields user only for mysql and postgres

* fixed build

* ack greptile comments

* fix PR comments

* remove search_id from parallel ai

* fix parallel ai params
2025-08-23 21:43:55 -07:00
Adam Gough
1038e148c3 fix autoconnect (#1127) 2025-08-23 20:46:03 -07:00
Adam Gough
8b78200991 fix(onedrive): fixed advanced mode (#1122)
* fixed onedrive advanced mode

* removed logger

* removed loger

* added a slack instruction

* remove folderId

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-23 19:49:13 -07:00
Siddharth Ganesan
c8f4791582 Feat/copilot client clean (#1118)
* SSE tool call v1 - not tested yet

* Handle tool call generation sse

* Add mark complete api

* copilot new progress

* Migrate get user workflow

* Run workflow migrated

* Migrate run workflow and remove some dead code

* Migrate gdrive request access

* Add server side execution logic

* Get block metadata migrated

* Build workflow progress

* Somewhat working condition, build still broken

* Stuff

* Get workflow console

* search online tool

* Set/get env vars

* oauth, gdrive list, gdrive read

* Search docs

* Build workflow update

* Edit workflow

* Migrate plan tool

* Checkoff

* Refactor

* Improvement

* checkpoint

* New store basics

* Generating adds to map

* Update

* Display v1

* Update

* Stuff

* Stuff

* Stuff

* Edit works

* Interrupt tool fixes

* Interrupt tool fixes

* Good progress

* new copilot to copilot

* Fix chat laoding

* Skip rendering of non registered tools

* Small fix

* Updates

* Updates

* Updates

* Update

* Some fixes

* Revert fixes

* run workflow

* Move to background button shows up

* User input scroll bar

* Lint

* Build errors

* Diff controls

* Restore ui

* Ui fixes

* Max mode ui

* Thinking text collapse

* Tool ui updates

* Mode selector UI

* Lint

* Ui

* Update icon

* Dummy test

* Lint
2025-08-23 18:11:10 -07:00
Vikhyath Mondreti
6c9e0ec88b improvement(logging): capture pre-execution validation errors in logging session (#1124)
* improvement(pre-exec-errors): capture pre-execution validation errors in logging session

* fix param shape for schedules

* fix naming
2025-08-23 18:08:57 -07:00
Adam Gough
bbbf1c2941 fix(teams-wh): fixed teams wh payload (#1119)
* first push

* fixed variable res

* lint

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-23 17:59:00 -07:00
Vikhyath Mondreti
efc487a845 improvement(chat-file-upload): add visual indication of file upload exceeding limit (#1123)
* improvement(chat-file-upload): add visual indication of file upload exceeding limit

* fix duplicate error + lint

* fix lint

* fix lint
2025-08-23 17:08:41 -07:00
Vikhyath Mondreti
5786909c5e fix(tag-dropdown): arrow navigation for submenu affecting text input cursor (#1121) 2025-08-23 16:19:45 -07:00
Vikhyath Mondreti
833c5fefd5 fix(logs): fix to remove retrieval of execution of data for basic version of call (#1120) 2025-08-23 15:51:08 -07:00
Adam Gough
79dd1ccb9f fix(ux): minor ux changes (#1109)
* minor UX fixes

* changed variable collapse

* lint

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-23 15:50:40 -07:00
Waleed Latif
730164abee fix(custom-tool): fix textarea, param dropdown for available params, validation for invalid schemas, variable resolution in custom tools and subflow tags (#1117)
* fix(custom-tools): fix text area for custom tools

* added param dropdown in agent custom tool

* add syntax highlighting for params, fix dropdown styling

* ux

* add tooltip to prevent indicate invalid json schema on schema and code tabs

* feat(custom-tool): added stricter JSON schema validation and error when saving json schema for custom tools

* fix(custom-tool): allow variable resolution in custom tools

* fix variable resolution in subflow tags

* refactored function execution to use helpers

* cleanup

* fix block variable resolution to inject at runtime

* fix highlighting code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-08-23 13:15:12 -07:00
Vikhyath Mondreti
25b2c45ec0 fix(billing): change reset user stats func to invoice payment succeeded (#1116)
* fix(billing): change reset user stats func to invoice payment succeeded

* remove nonexistent billing reason
2025-08-23 10:50:23 -07:00
Vikhyath Mondreti
780870c48e fix(billing): make subscription table source of truth for period start and period end (#1114)
* fix(billing): vercel cron not processing billing periods

* fix(billing): cleanup unused POST and fix bug with billing timing check

* make subscriptions table source of truth for dates

* update org routes

* make everything dependent on stripe webhook

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-23 10:25:41 -07:00
Vikhyath Mondreti
fdfa935a09 v0.3.38: billing cron job fix 2025-08-22 17:03:36 -07:00
Vikhyath Mondreti
917552f041 fix(billing): vercel cron not processing billing periods (#1112) 2025-08-22 16:52:31 -07:00
Waleed Latif
4846f6c60d v0.3.37: azure OCR api key, wand SSE, CRON helm 2025-08-22 14:54:36 -07:00
Vikhyath Mondreti
be810013c7 feat(native-bg-tasks): support webhooks and async workflow executions without trigger.dev (#1106)
* feat(native-bg-tasks): support webhooks and async workflow executions without trigger"

* fix tests

* fix env var defaults and revert async workflow execution to always use trigger

* fix UI for hiding async

* hide entire toggle
2025-08-22 14:43:21 -07:00
Waleed Latif
1ee4263e60 feat(helm): added CRON jobs to helm charts (#1107) 2025-08-22 14:29:44 -07:00
Waleed Latif
60c4668682 fix(naming): prevent identical normalized block names (#1105) 2025-08-22 13:20:45 -07:00
Emir Karabeg
a268fb7c04 fix(chat-deploy): dark mode ui (#1101) 2025-08-22 12:23:11 -07:00
Waleed Latif
6c606750f5 improvement(signup): modify signup and login pages to not show social sign in when not configured, increase logo size (#1103) 2025-08-22 12:15:59 -07:00
Waleed Latif
e13adab14f improvement(wand): upgrade wand to use SSE (#1100)
* improvement(wand): upgrade wand to use SSE

* fix(ocr-azure): added OCR_AZURE_API_KEY envvar (#1102)

* make wand identical to chat panel
2025-08-22 12:01:16 -07:00
Waleed Latif
44bc12b474 fix(ocr-azure): added OCR_AZURE_API_KEY envvar (#1102) 2025-08-22 11:49:56 -07:00
Waleed Latif
991f0442e9 v0.3.36: workflow block logs, whitelabeling configurability, session provider 2025-08-21 21:44:28 -07:00
Waleed Latif
2ebfb576ae fix(day-picker): remove unused react-day-picker (#1094) 2025-08-21 21:29:20 -07:00
Vikhyath Mondreti
11a7be54f2 fix circular dependsOn for Jira manualIssueKey 2025-08-21 21:21:19 -07:00
Vikhyath Mondreti
f5219d03c3 fix(ms-oauth): oauth edge cases (#1093) 2025-08-21 21:19:11 -07:00
Waleed Latif
f0643e01b4 fix(logs): make child workflow span errors the same as root level workflow errors (#1092) 2025-08-21 21:17:09 -07:00
Adam Gough
77b0c5b9ed Fix(excel-range): fixed excel range (#1088)
* added auto range

* lint

* removed any

* utils file

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-21 20:04:20 -07:00
Adam Gough
9dbd44e555 fix(webhook-payloads): fixed the variable resolution in webhooks (#1019)
* telegram webhook fix

* changed payloads

* test

* test

* test

* test

* fix github dropdown

* test

* reverted github changes

* fixed github var

* test

* bun run lint

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test push

* test

* bun run lint

* edited airtable payload and webhook deletion

* Revert bun.lock and package.json to upstream/staging

* cleaned up

* test

* test

* resolving more cmments

* resolved comments, updated trigger

* cleaned up, resolved comments

* test

* test

* lint

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-21 20:03:04 -07:00
Waleed Latif
9ea9f2d52e improvement(log-level): make log level configurable via envvar (#1091) 2025-08-21 19:40:47 -07:00
Waleed Latif
4cd707fadb improvement(emails): fixed email subjects to use provided brand name (#1090)
* improvement(emails): fixed email subjects to use provided brand name

* update manifest to use dynamic background & theme color
2025-08-21 19:34:05 -07:00
Waleed Latif
f0b07428bc feat(theme): added custom envvars for themes (#1089)
* feat(theme): added custom envvars for themes

* add regec
2025-08-21 19:27:56 -07:00
Vikhyath Mondreti
8c9e182e10 fix(infinite-get-session): pass session once per tree using session provider + multiple fixes (#1085)
* fix(infinite-get-session): pass session using session provider

* prevent auto refetch

* fix typing:

* fix types

* fix

* fix oauth token for microsoft file selector

* fix start block required error
2025-08-21 18:45:15 -07:00
Waleed Latif
33dd59f7a7 fix(db-consts): make the migrations image fully standalone by adding db consts (#1087) 2025-08-21 17:25:35 -07:00
Waleed Latif
53ee9f99db fix(templates): added option to delete/keep templates when deleting workspace, updated template modal, sidebar code cleanup (#1086)
* feat(templates): added in the ability to keep/remove templates when deleting workspace

* code cleanup in sidebar

* add the ability to edit existing templates

* updated template modal

* fix build

* revert bun.lock

* add template logic to workflow deletion as well

* add ability to delete templates

* add owner/admin enforcemnet to modify or delete templates
2025-08-21 17:11:22 -07:00
Vikhyath Mondreti
0f2a125eae improvement(block-error-logs): workflow in workflow (#1084)
* improvement(add-block-logs): workflow in workflow

* fix lint
2025-08-21 15:01:30 -07:00
Waleed Latif
e107363ea7 v0.3.35: migrations, custom email address support 2025-08-21 12:36:51 -07:00
Waleed Latif
7e364a7977 fix(emails): remove unused useCustomFromFormat param (#1082)
* fix(mailer): remove unused useCustomFormat

* bun.lock changes
2025-08-21 12:09:03 -07:00
Waleed Latif
35a37d8b45 fix(acs): added FROM_EMAIL_ADDRESS envvar for ACS (#1081)
* fix: clear Docker build cache to use correct Next.js version

* fix(mailer): add FROM_EMAIL_ADDRESS envvar for ACS

* bun.lock

* added tests
2025-08-21 11:57:44 -07:00
Vikhyath Mondreti
2b52d88cee fix(migrations): add missing migration for document table (#1080)
* fix(migrations): add missing migration for document table

* add newline at end of file
2025-08-21 11:48:54 -07:00
Waleed Latif
abad3620a3 fix(build): clear docker build cache to use correct Next.js version 2025-08-21 01:43:45 -07:00
Waleed Latif
a37c6bc812 fix(build): clear docker build cache to use correct Next.js version (#1075)
* fix: clear Docker build cache to use correct Next.js version

- Changed GitHub Actions cache scope from build-v2 to build-v3
- This should force a fresh build without cached Next.js 15.5.0 layers
- Reverted to ^15.3.2 version format that worked on main branch

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

Co-Authored-By: Claude <noreply@anthropic.com>

* run install

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-21 01:38:47 -07:00
Waleed Latif
cd1bd95952 fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:24:06 -07:00
Waleed Latif
4c9fdbe7fb fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:23:10 -07:00
281 changed files with 21595 additions and 13759 deletions

View File

@@ -85,8 +85,8 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=build-v2
cache-to: type=gha,mode=max,scope=build-v2
cache-from: type=gha,scope=build-v3
cache-to: type=gha,mode=max,scope=build-v3
provenance: false
sbom: false

View File

@@ -33,12 +33,15 @@
"microsoft_planner",
"microsoft_teams",
"mistral_parse",
"mysql",
"notion",
"onedrive",
"openai",
"outlook",
"parallel_ai",
"perplexity",
"pinecone",
"postgresql",
"qdrant",
"reddit",
"s3",

View File

@@ -0,0 +1,180 @@
---
title: MySQL
description: Connect to MySQL database
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mysql"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 25.6 25.6'
>
<path
d='M179.076 94.886c-3.568-.1-6.336.268-8.656 1.25-.668.27-1.74.27-1.828 1.116.357.355.4.936.713 1.428.535.893 1.473 2.096 2.32 2.72l2.855 2.053c1.74 1.07 3.703 1.695 5.398 2.766.982.625 1.963 1.428 2.945 2.098.5.357.803.938 1.428 1.16v-.135c-.312-.4-.402-.98-.713-1.428l-1.34-1.293c-1.293-1.74-2.9-3.258-4.64-4.506-1.428-.982-4.55-2.32-5.13-3.97l-.088-.1c.98-.1 2.14-.447 3.078-.715 1.518-.4 2.9-.312 4.46-.713l2.143-.625v-.4c-.803-.803-1.383-1.874-2.23-2.632-2.275-1.963-4.775-3.882-7.363-5.488-1.383-.892-3.168-1.473-4.64-2.23-.537-.268-1.428-.402-1.74-.848-.805-.98-1.25-2.275-1.83-3.436l-3.658-7.763c-.803-1.74-1.295-3.48-2.275-5.086-4.596-7.585-9.594-12.18-17.268-16.687-1.65-.937-3.613-1.34-5.7-1.83l-3.346-.18c-.715-.312-1.428-1.16-2.053-1.562-2.543-1.606-9.102-5.086-10.977-.5-1.205 2.9 1.785 5.755 2.8 7.228.76 1.026 1.74 2.186 2.277 3.346.3.758.4 1.562.713 2.365.713 1.963 1.383 4.15 2.32 5.98.5.937 1.025 1.92 1.65 2.767.357.5.982.714 1.115 1.517-.625.893-.668 2.23-1.025 3.347-1.607 5.042-.982 11.288 1.293 15 .715 1.115 2.4 3.57 4.686 2.632 2.008-.803 1.56-3.346 2.14-5.577.135-.535.045-.892.312-1.25v.1l1.83 3.703c1.383 2.186 3.793 4.462 5.8 5.98 1.07.803 1.918 2.187 3.256 2.677v-.135h-.088c-.268-.4-.67-.58-1.027-.892-.803-.803-1.695-1.785-2.32-2.677-1.873-2.498-3.523-5.265-4.996-8.12-.715-1.383-1.34-2.9-1.918-4.283-.27-.536-.27-1.34-.715-1.606-.67.98-1.65 1.83-2.143 3.034-.848 1.918-.936 4.283-1.248 6.737-.18.045-.1 0-.18.1-1.426-.356-1.918-1.83-2.453-3.078-1.338-3.168-1.562-8.254-.402-11.913.312-.937 1.652-3.882 1.117-4.774-.27-.848-1.16-1.338-1.652-2.008-.58-.848-1.203-1.918-1.605-2.855-1.07-2.5-1.605-5.265-2.766-7.764-.537-1.16-1.473-2.365-2.232-3.435-.848-1.205-1.783-2.053-2.453-3.48-.223-.5-.535-1.294-.178-1.83.088-.357.268-.5.623-.58.58-.5 2.232.134 2.812.4 1.65.67 3.033 1.294 4.416 2.23.625.446 1.295 1.294 2.098 1.518h.938c1.428.312 3.033.1 4.37.5 2.365.76 4.506 1.874 6.426 3.08 5.844 3.703 10.664 8.968 13.92 15.26.535 1.026.758 1.963 1.25 3.034.938 2.187 2.098 4.417 3.033 6.56.938 2.097 1.83 4.24 3.168 5.98.67.937 3.346 1.427 4.55 1.918.893.4 2.275.76 3.08 1.25 1.516.937 3.033 2.008 4.46 3.034.713.534 2.945 1.65 3.078 2.54zm-45.5-38.772a7.09 7.09 0 0 0-1.828.223v.1h.088c.357.714.982 1.205 1.428 1.83l1.027 2.142.088-.1c.625-.446.938-1.16.938-2.23-.268-.312-.312-.625-.535-.937-.268-.446-.848-.67-1.206-1.026z'
transform='matrix(.390229 0 0 .38781 -46.300037 -16.856717)'
fillRule='evenodd'
fill='#00678c'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
The [MySQL](https://www.mysql.com/) tool enables you to connect to any MySQL database and perform a wide range of database operations directly within your agentic workflows. With secure connection handling and flexible configuration, you can easily manage and interact with your data.
With the MySQL tool, you can:
- **Query data**: Execute SELECT queries to retrieve data from your MySQL tables using the `mysql_query` operation.
- **Insert records**: Add new rows to your tables with the `mysql_insert` operation by specifying the table and data to insert.
- **Update records**: Modify existing data in your tables using the `mysql_update` operation, providing the table, new data, and WHERE conditions.
- **Delete records**: Remove rows from your tables with the `mysql_delete` operation, specifying the table and WHERE conditions.
- **Execute raw SQL**: Run any custom SQL command using the `mysql_execute` operation for advanced use cases.
The MySQL tool is ideal for scenarios where your agents need to interact with structured data—such as automating reporting, syncing data between systems, or powering data-driven workflows. It streamlines database access, making it easy to read, write, and manage your MySQL data programmatically.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to any MySQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
## Tools
### `mysql_query`
Execute SELECT query on MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | SQL SELECT query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows returned |
### `mysql_insert`
Insert new record into MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to insert into |
| `data` | object | Yes | Data to insert as key-value pairs |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of inserted rows |
| `rowCount` | number | Number of rows inserted |
### `mysql_update`
Update existing records in MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to update |
| `data` | object | Yes | Data to update as key-value pairs |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of updated rows |
| `rowCount` | number | Number of rows updated |
### `mysql_delete`
Delete records from MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to delete from |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of deleted rows |
| `rowCount` | number | Number of rows deleted |
### `mysql_execute`
Execute raw SQL query on MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | Raw SQL query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows affected |
## Notes
- Category: `tools`
- Type: `mysql`

View File

@@ -0,0 +1,106 @@
---
title: Parallel AI
description: Search with Parallel AI
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="parallel_ai"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
fill='currentColor'
viewBox='0 0 271 270'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M267.804 105.65H193.828C194.026 106.814 194.187 107.996 194.349 109.178H76.6703C76.4546 110.736 76.2388 112.312 76.0591 113.87H1.63342C1.27387 116.198 0.950289 118.543 0.698608 120.925H75.3759C75.2501 122.483 75.1602 124.059 75.0703 125.617H195.949C196.003 126.781 196.057 127.962 196.093 129.144H270.68V125.384C270.195 118.651 269.242 112.061 267.804 105.65Z'
fill='#1D1C1A'
/>
<path
d='M195.949 144.401H75.0703C75.1422 145.977 75.2501 147.535 75.3759 149.093H0.698608C0.950289 151.457 1.2559 153.802 1.63342 156.148H76.0591C76.2388 157.724 76.4366 159.282 76.6703 160.84H194.349C194.187 162.022 194.008 163.186 193.828 164.367H267.804C269.242 157.957 270.195 151.367 270.68 144.634V140.874H196.093C196.057 142.055 196.003 143.219 195.949 144.401Z'
fill='#1D1C1A'
/>
<path
d='M190.628 179.642H80.3559C80.7514 181.218 81.1828 182.776 81.6143 184.334H9.30994C10.2448 186.715 11.2515 189.061 12.3121 191.389H83.7536C84.2749 192.965 84.7962 194.523 85.3535 196.08H185.594C185.163 197.262 184.732 198.426 184.282 199.608H254.519C258.6 192.177 261.98 184.316 264.604 176.114H191.455C191.185 177.296 190.898 178.46 190.61 179.642H190.628Z'
fill='#1D1C1A'
/>
<path
d='M177.666 214.883H93.3352C94.1082 216.458 94.9172 218.034 95.7441 219.574H29.8756C31.8351 221.992 33.8666 224.337 35.9699 226.63H99.6632C100.598 228.205 101.551 229.781 102.522 231.321H168.498C167.761 232.503 167.006 233.685 166.233 234.849H226.762C234.474 227.847 241.36 219.95 247.292 211.355H179.356C178.799 212.537 178.26 213.719 177.684 214.883H177.666Z'
fill='#1D1C1A'
/>
<path
d='M154.943 250.106H116.058C117.371 251.699 118.701 253.257 120.067 254.797H73.021C91.6094 264.431 112.715 269.946 135.096 270C135.24 270 135.366 270 135.492 270C135.618 270 135.761 270 135.887 270C164.04 269.911 190.178 261.28 211.805 246.56H157.748C156.813 247.742 155.878 248.924 154.925 250.088L154.943 250.106Z'
fill='#1D1C1A'
/>
<path
d='M116.059 19.9124H154.943C155.896 21.0764 156.831 22.2582 157.766 23.4401H211.823C190.179 8.72065 164.058 0.0895344 135.906 0C135.762 0 135.636 0 135.51 0C135.384 0 135.24 0 135.115 0C112.715 0.0716275 91.6277 5.56904 73.0393 15.2029H120.086C118.719 16.7429 117.389 18.3187 116.077 19.8945L116.059 19.9124Z'
fill='#1D1C1A'
/>
<path
d='M93.3356 55.1532H177.667C178.242 56.3171 178.799 57.499 179.339 58.6808H247.274C241.342 50.0855 234.457 42.1886 226.744 35.187H166.215C166.988 36.351 167.743 37.5328 168.48 38.7147H102.504C101.533 40.2726 100.58 41.8305 99.6456 43.4063H35.9523C33.831 45.6804 31.7996 48.0262 29.858 50.4616H95.7265C94.8996 52.0195 94.1086 53.5774 93.3176 55.1532H93.3356Z'
fill='#1D1C1A'
/>
<path
d='M80.3736 90.3758H190.646C190.933 91.5398 191.221 92.7216 191.491 93.9035H264.64C262.015 85.7021 258.636 77.841 254.555 70.4097H184.318C184.767 71.5736 185.199 72.7555 185.63 73.9373H85.3893C84.832 75.4952 84.2927 77.0531 83.7893 78.6289H12.3479C11.2872 80.9389 10.2805 83.2847 9.3457 85.6842H81.65C81.2186 87.2421 80.7871 88.8 80.3916 90.3758H80.3736Z'
fill='#1D1C1A'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Parallel AI](https://parallel.ai/) is an advanced web search and content extraction platform designed to deliver comprehensive, high-quality results for any query. By leveraging intelligent processing and large-scale data extraction, Parallel AI enables users and agents to access, analyze, and synthesize information from across the web with speed and accuracy.
With Parallel AI, you can:
- **Search the web intelligently**: Retrieve relevant, up-to-date information from a wide range of sources
- **Extract and summarize content**: Get concise, meaningful excerpts from web pages and documents
- **Customize search objectives**: Tailor queries to specific needs or questions for targeted results
- **Process results at scale**: Handle large volumes of search results with advanced processing options
- **Integrate with workflows**: Use Parallel AI within Sim to automate research, content gathering, and knowledge extraction
- **Control output granularity**: Specify the number of results and the amount of content per result
- **Secure API access**: Protect your searches and data with API key authentication
In Sim, the Parallel AI integration empowers your agents to perform web searches and extract content programmatically. This enables powerful automation scenarios such as real-time research, competitive analysis, content monitoring, and knowledge base creation. By connecting Sim with Parallel AI, you unlock the ability for agents to gather, process, and utilize web data as part of your automated workflows.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Search the web using Parallel AI's advanced search capabilities. Get comprehensive results with intelligent processing and content extraction.
## Tools
### `parallel_search`
Search the web using Parallel AI. Provides comprehensive search results with intelligent processing and content extraction.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `objective` | string | Yes | The search objective or question to answer |
| `search_queries` | string | No | Optional comma-separated list of search queries to execute |
| `processor` | string | No | Processing method: base or pro \(default: base\) |
| `max_results` | number | No | Maximum number of results to return \(default: 5\) |
| `max_chars_per_result` | number | No | Maximum characters per result \(default: 1500\) |
| `apiKey` | string | Yes | Parallel AI API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | array | Search results with excerpts from relevant pages |
## Notes
- Category: `tools`
- Type: `parallel_ai`

View File

@@ -0,0 +1,188 @@
---
title: PostgreSQL
description: Connect to PostgreSQL database
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="postgresql"
color="#336791"
icon={true}
iconSvg={`<svg className="block-icon"
viewBox='-4 0 264 264'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMinYMin meet'
>
<path d='M255.008 158.086c-1.535-4.649-5.556-7.887-10.756-8.664-2.452-.366-5.26-.21-8.583.475-5.792 1.195-10.089 1.65-13.225 1.738 11.837-19.985 21.462-42.775 27.003-64.228 8.96-34.689 4.172-50.492-1.423-57.64C233.217 10.847 211.614.683 185.552.372c-13.903-.17-26.108 2.575-32.475 4.549-5.928-1.046-12.302-1.63-18.99-1.738-12.537-.2-23.614 2.533-33.079 8.15-5.24-1.772-13.65-4.27-23.362-5.864-22.842-3.75-41.252-.828-54.718 8.685C6.622 25.672-.937 45.684.461 73.634c.444 8.874 5.408 35.874 13.224 61.48 4.492 14.718 9.282 26.94 14.237 36.33 7.027 13.315 14.546 21.156 22.987 23.972 4.731 1.576 13.327 2.68 22.368-4.85 1.146 1.388 2.675 2.767 4.704 4.048 2.577 1.625 5.728 2.953 8.875 3.74 11.341 2.835 21.964 2.126 31.027-1.848.056 1.612.099 3.152.135 4.482.06 2.157.12 4.272.199 6.25.537 13.374 1.447 23.773 4.143 31.049.148.4.347 1.01.557 1.657 1.345 4.118 3.594 11.012 9.316 16.411 5.925 5.593 13.092 7.308 19.656 7.308 3.292 0 6.433-.432 9.188-1.022 9.82-2.105 20.973-5.311 29.041-16.799 7.628-10.86 11.336-27.217 12.007-52.99.087-.729.167-1.425.244-2.088l.16-1.362 1.797.158.463.031c10.002.456 22.232-1.665 29.743-5.154 5.935-2.754 24.954-12.795 20.476-26.351' />
<path
d='M237.906 160.722c-29.74 6.135-31.785-3.934-31.785-3.934 31.4-46.593 44.527-105.736 33.2-120.211-30.904-39.485-84.399-20.811-85.292-20.327l-.287.052c-5.876-1.22-12.451-1.946-19.842-2.067-13.456-.22-23.664 3.528-31.41 9.402 0 0-95.43-39.314-90.991 49.444.944 18.882 27.064 142.873 58.218 105.422 11.387-13.695 22.39-25.274 22.39-25.274 5.464 3.63 12.006 5.482 18.864 4.817l.533-.452c-.166 1.7-.09 3.363.213 5.332-8.026 8.967-5.667 10.541-21.711 13.844-16.235 3.346-6.698 9.302-.471 10.86 7.549 1.887 25.013 4.561 36.813-11.958l-.47 1.885c3.144 2.519 5.352 16.383 4.982 28.952-.37 12.568-.617 21.197 1.86 27.937 2.479 6.74 4.948 21.905 26.04 17.386 17.623-3.777 26.756-13.564 28.027-29.89.901-11.606 2.942-9.89 3.07-20.267l1.637-4.912c1.887-15.733.3-20.809 11.157-18.448l2.64.232c7.99.363 18.45-1.286 24.589-4.139 13.218-6.134 21.058-16.377 8.024-13.686h.002'
fill='#336791'
/>
<path
d='M108.076 81.525c-2.68-.373-5.107-.028-6.335.902-.69.523-.904 1.129-.962 1.546-.154 1.105.62 2.327 1.096 2.957 1.346 1.784 3.312 3.01 5.258 3.28.282.04.563.058.842.058 3.245 0 6.196-2.527 6.456-4.392.325-2.336-3.066-3.893-6.355-4.35M196.86 81.599c-.256-1.831-3.514-2.353-6.606-1.923-3.088.43-6.082 1.824-5.832 3.659.2 1.427 2.777 3.863 5.827 3.863.258 0 .518-.017.78-.054 2.036-.282 3.53-1.575 4.24-2.32 1.08-1.136 1.706-2.402 1.591-3.225'
fill='#FFF'
/>
<path
d='M247.802 160.025c-1.134-3.429-4.784-4.532-10.848-3.28-18.005 3.716-24.453 1.142-26.57-.417 13.995-21.32 25.508-47.092 31.719-71.137 2.942-11.39 4.567-21.968 4.7-30.59.147-9.463-1.465-16.417-4.789-20.665-13.402-17.125-33.072-26.311-56.882-26.563-16.369-.184-30.199 4.005-32.88 5.183-5.646-1.404-11.801-2.266-18.502-2.376-12.288-.199-22.91 2.743-31.704 8.74-3.82-1.422-13.692-4.811-25.765-6.756-20.872-3.36-37.458-.814-49.294 7.571-14.123 10.006-20.643 27.892-19.38 53.16.425 8.501 5.269 34.653 12.913 59.698 10.062 32.964 21 51.625 32.508 55.464 1.347.449 2.9.763 4.613.763 4.198 0 9.345-1.892 14.7-8.33a529.832 529.832 0 0 1 20.261-22.926c4.524 2.428 9.494 3.784 14.577 3.92.01.133.023.266.035.398a117.66 117.66 0 0 0-2.57 3.175c-3.522 4.471-4.255 5.402-15.592 7.736-3.225.666-11.79 2.431-11.916 8.435-.136 6.56 10.125 9.315 11.294 9.607 4.074 1.02 7.999 1.523 11.742 1.523 9.103 0 17.114-2.992 23.516-8.781-.197 23.386.778 46.43 3.586 53.451 2.3 5.748 7.918 19.795 25.664 19.794 2.604 0 5.47-.303 8.623-.979 18.521-3.97 26.564-12.156 29.675-30.203 1.665-9.645 4.522-32.676 5.866-45.03 2.836.885 6.487 1.29 10.434 1.289 8.232 0 17.731-1.749 23.688-4.514 6.692-3.108 18.768-10.734 16.578-17.36zm-44.106-83.48c-.061 3.647-.563 6.958-1.095 10.414-.573 3.717-1.165 7.56-1.314 12.225-.147 4.54.42 9.26.968 13.825 1.108 9.22 2.245 18.712-2.156 28.078a36.508 36.508 0 0 1-1.95-4.009c-.547-1.326-1.735-3.456-3.38-6.404-6.399-11.476-21.384-38.35-13.713-49.316 2.285-3.264 8.084-6.62 22.64-4.813zm-17.644-61.787c21.334.471 38.21 8.452 50.158 23.72 9.164 11.711-.927 64.998-30.14 110.969a171.33 171.33 0 0 0-.886-1.117l-.37-.462c7.549-12.467 6.073-24.802 4.759-35.738-.54-4.488-1.05-8.727-.92-12.709.134-4.22.692-7.84 1.232-11.34.663-4.313 1.338-8.776 1.152-14.037.139-.552.195-1.204.122-1.978-.475-5.045-6.235-20.144-17.975-33.81-6.422-7.475-15.787-15.84-28.574-21.482 5.5-1.14 13.021-2.203 21.442-2.016zM66.674 175.778c-5.9 7.094-9.974 5.734-11.314 5.288-8.73-2.912-18.86-21.364-27.791-50.624-7.728-25.318-12.244-50.777-12.602-57.916-1.128-22.578 4.345-38.313 16.268-46.769 19.404-13.76 51.306-5.524 64.125-1.347-.184.182-.376.352-.558.537-21.036 21.244-20.537 57.54-20.485 59.759-.002.856.07 2.068.168 3.735.362 6.105 1.036 17.467-.764 30.334-1.672 11.957 2.014 23.66 10.111 32.109a36.275 36.275 0 0 0 2.617 2.468c-3.604 3.86-11.437 12.396-19.775 22.426zm22.479-29.993c-6.526-6.81-9.49-16.282-8.133-25.99 1.9-13.592 1.199-25.43.822-31.79-.053-.89-.1-1.67-.127-2.285 3.073-2.725 17.314-10.355 27.47-8.028 4.634 1.061 7.458 4.217 8.632 9.645 6.076 28.103.804 39.816-3.432 49.229-.873 1.939-1.698 3.772-2.402 5.668l-.546 1.466c-1.382 3.706-2.668 7.152-3.465 10.424-6.938-.02-13.687-2.984-18.819-8.34zm1.065 37.9c-2.026-.506-3.848-1.385-4.917-2.114.893-.42 2.482-.992 5.238-1.56 13.337-2.745 15.397-4.683 19.895-10.394 1.031-1.31 2.2-2.794 3.819-4.602l.002-.002c2.411-2.7 3.514-2.242 5.514-1.412 1.621.67 3.2 2.702 3.84 4.938.303 1.056.643 3.06-.47 4.62-9.396 13.156-23.088 12.987-32.921 10.526zm69.799 64.952c-16.316 3.496-22.093-4.829-25.9-14.346-2.457-6.144-3.665-33.85-2.808-64.447.011-.407-.047-.8-.159-1.17a15.444 15.444 0 0 0-.456-2.162c-1.274-4.452-4.379-8.176-8.104-9.72-1.48-.613-4.196-1.738-7.46-.903.696-2.868 1.903-6.107 3.212-9.614l.549-1.475c.618-1.663 1.394-3.386 2.214-5.21 4.433-9.848 10.504-23.337 3.915-53.81-2.468-11.414-10.71-16.988-23.204-15.693-7.49.775-14.343 3.797-17.761 5.53-.735.372-1.407.732-2.035 1.082.954-11.5 4.558-32.992 18.04-46.59 8.489-8.56 19.794-12.788 33.568-12.56 27.14.444 44.544 14.372 54.366 25.979 8.464 10.001 13.047 20.076 14.876 25.51-13.755-1.399-23.11 1.316-27.852 8.096-10.317 14.748 5.644 43.372 13.315 57.129 1.407 2.521 2.621 4.7 3.003 5.626 2.498 6.054 5.732 10.096 8.093 13.046.724.904 1.426 1.781 1.96 2.547-4.166 1.201-11.649 3.976-10.967 17.847-.55 6.96-4.461 39.546-6.448 51.059-2.623 15.21-8.22 20.875-23.957 24.25zm68.104-77.936c-4.26 1.977-11.389 3.46-18.161 3.779-7.48.35-11.288-.838-12.184-1.569-.42-8.644 2.797-9.547 6.202-10.503.535-.15 1.057-.297 1.561-.473.313.255.656.508 1.032.756 6.012 3.968 16.735 4.396 31.874 1.271l.166-.033c-2.042 1.909-5.536 4.471-10.49 6.772z'
fill='#FFF'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
The [PostgreSQL](https://www.postgresql.org/) tool enables you to connect to any PostgreSQL database and perform a wide range of database operations directly within your agentic workflows. With secure connection handling and flexible configuration, you can easily manage and interact with your data.
With the PostgreSQL tool, you can:
- **Query data**: Execute SELECT queries to retrieve data from your PostgreSQL tables using the `postgresql_query` operation.
- **Insert records**: Add new rows to your tables with the `postgresql_insert` operation by specifying the table and data to insert.
- **Update records**: Modify existing data in your tables using the `postgresql_update` operation, providing the table, new data, and WHERE conditions.
- **Delete records**: Remove rows from your tables with the `postgresql_delete` operation, specifying the table and WHERE conditions.
- **Execute raw SQL**: Run any custom SQL command using the `postgresql_execute` operation for advanced use cases.
The PostgreSQL tool is ideal for scenarios where your agents need to interact with structured data—such as automating reporting, syncing data between systems, or powering data-driven workflows. It streamlines database access, making it easy to read, write, and manage your PostgreSQL data programmatically.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to any PostgreSQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
## Tools
### `postgresql_query`
Execute a SELECT query on PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | SQL SELECT query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows returned |
### `postgresql_insert`
Insert data into PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to insert data into |
| `data` | object | Yes | Data object to insert \(key-value pairs\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Inserted data \(if RETURNING clause used\) |
| `rowCount` | number | Number of rows inserted |
### `postgresql_update`
Update data in PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to update data in |
| `data` | object | Yes | Data object with fields to update \(key-value pairs\) |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Updated data \(if RETURNING clause used\) |
| `rowCount` | number | Number of rows updated |
### `postgresql_delete`
Delete data from PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to delete data from |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Deleted data \(if RETURNING clause used\) |
| `rowCount` | number | Number of rows deleted |
### `postgresql_execute`
Execute raw SQL query on PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | Raw SQL query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows affected |
## Notes
- Category: `tools`
- Type: `postgresql`

View File

@@ -3,7 +3,6 @@
import { useEffect, useState } from 'react'
import { GithubIcon, GoogleIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { client } from '@/lib/auth-client'
interface SocialLoginButtonsProps {
@@ -114,58 +113,16 @@ export function SocialLoginButtons({
</Button>
)
const renderGithubButton = () => {
if (githubAvailable) return githubButton
const hasAnyOAuthProvider = githubAvailable || googleAvailable
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{githubButton}</div>
</TooltipTrigger>
<TooltipContent className='border-neutral-700 bg-neutral-800 text-white'>
<p>
GitHub login requires OAuth credentials to be configured. Add the following
environment variables:
</p>
<ul className='mt-2 space-y-1 text-neutral-300 text-xs'>
<li> GITHUB_CLIENT_ID</li>
<li> GITHUB_CLIENT_SECRET</li>
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const renderGoogleButton = () => {
if (googleAvailable) return googleButton
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{googleButton}</div>
</TooltipTrigger>
<TooltipContent className='border-neutral-700 bg-neutral-800 text-white'>
<p>
Google login requires OAuth credentials to be configured. Add the following
environment variables:
</p>
<ul className='mt-2 space-y-1 text-neutral-300 text-xs'>
<li> GOOGLE_CLIENT_ID</li>
<li> GOOGLE_CLIENT_SECRET</li>
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
if (!hasAnyOAuthProvider) {
return null
}
return (
<div className='grid gap-3'>
{renderGithubButton()}
{renderGoogleButton()}
{githubAvailable && githubButton}
{googleAvailable && googleButton}
</div>
)
}

View File

@@ -28,12 +28,12 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
<img
src={brand.logoUrl}
alt={`${brand.name} Logo`}
width={42}
height={42}
className='h-[42px] w-[42px] object-contain'
width={56}
height={56}
className='h-[56px] w-[56px] object-contain'
/>
) : (
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={42} height={42} />
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={56} height={56} />
)}
</Link>
</div>

View File

@@ -366,11 +366,13 @@ export default function LoginPage({
callbackURL={callbackUrl}
/>
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
{(githubAvailable || googleAvailable) && (
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
</div>
</div>
</div>
)}
<form onSubmit={onSubmit} className='space-y-5'>
<div className='space-y-4'>

View File

@@ -381,11 +381,13 @@ function SignupFormContent({
isProduction={isProduction}
/>
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
{(githubAvailable || googleAvailable) && (
<div className='relative mt-2 py-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-neutral-700/50 border-t' />
</div>
</div>
</div>
)}
<form onSubmit={onSubmit} className='space-y-5'>
<div className='space-y-4'>

View File

@@ -354,6 +354,18 @@ export function mockExecutionDependencies() {
}))
}
/**
* Mock Trigger.dev SDK (tasks.trigger and task factory) for tests that import background modules
*/
export function mockTriggerDevSdk() {
vi.mock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
task: vi.fn().mockReturnValue({}),
}))
}
export function mockWorkflowAccessValidation(shouldSucceed = true) {
if (shouldSucceed) {
vi.mock('@/app/api/workflows/middleware', () => ({

View File

@@ -4,8 +4,9 @@ import { auth } from '@/lib/auth'
export async function POST() {
try {
const hdrs = await headers()
const response = await auth.api.generateOneTimeToken({
headers: await headers(),
headers: hdrs,
})
if (!response) {
@@ -14,7 +15,6 @@ export async function POST() {
return NextResponse.json({ token: response.token })
} catch (error) {
console.error('Error generating one-time token:', error)
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
}

View File

@@ -1,109 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { processDailyBillingCheck } from '@/lib/billing/core/billing'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DailyBillingCron')
/**
* Daily billing CRON job endpoint that checks individual billing periods
*/
export async function POST(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'daily billing check')
if (authError) {
return authError
}
logger.info('Starting daily billing check cron job')
const startTime = Date.now()
// Process overage billing for users and organizations with periods ending today
const result = await processDailyBillingCheck()
const duration = Date.now() - startTime
if (result.success) {
logger.info('Daily billing check completed successfully', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
})
return NextResponse.json({
success: true,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
},
})
}
logger.error('Daily billing check completed with errors', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
errors: result.errors,
duration: `${duration}ms`,
})
return NextResponse.json(
{
success: false,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
duration: `${duration}ms`,
},
errors: result.errors,
},
{ status: 500 }
)
} catch (error) {
logger.error('Fatal error in monthly billing cron job', { error })
return NextResponse.json(
{
success: false,
error: 'Internal server error during daily billing check',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET endpoint for manual testing and health checks
*/
export async function GET(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'daily billing check health check')
if (authError) {
return authError
}
return NextResponse.json({
status: 'ready',
message:
'Daily billing check cron job is ready to process users and organizations with periods ending today',
currentDate: new Date().toISOString().split('T')[0],
})
} catch (error) {
logger.error('Error in billing health check', { error })
return NextResponse.json(
{
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -143,7 +143,7 @@ async function generateChatTitleAsync(
streamController?: ReadableStreamDefaultController<Uint8Array>
): Promise<void> {
try {
logger.info(`[${requestId}] Starting async title generation for chat ${chatId}`)
// logger.info(`[${requestId}] Starting async title generation for chat ${chatId}`)
const title = await generateChatTitle(userMessage)
@@ -167,7 +167,7 @@ async function generateChatTitleAsync(
logger.debug(`[${requestId}] Sent title_updated event to client: "${title}"`)
}
logger.info(`[${requestId}] Generated title for chat ${chatId}: "${title}"`)
// logger.info(`[${requestId}] Generated title for chat ${chatId}: "${title}"`)
} catch (error) {
logger.error(`[${requestId}] Failed to generate title for chat ${chatId}:`, error)
// Don't throw - this is a background operation
@@ -229,21 +229,21 @@ export async function POST(req: NextRequest) {
}
}
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
userId: authenticatedUserId,
workflowId,
chatId,
mode,
stream,
createNewChat,
messageLength: message.length,
hasImplicitFeedback: !!implicitFeedback,
provider: provider || 'openai',
hasConversationId: !!conversationId,
depth,
prefetch,
origin: requestOrigin,
})
// logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
// userId: authenticatedUserId,
// workflowId,
// chatId,
// mode,
// stream,
// createNewChat,
// messageLength: message.length,
// hasImplicitFeedback: !!implicitFeedback,
// provider: provider || 'openai',
// hasConversationId: !!conversationId,
// depth,
// prefetch,
// origin: requestOrigin,
// })
// Handle chat context
let currentChat: any = null
@@ -285,7 +285,7 @@ export async function POST(req: NextRequest) {
// Process file attachments if present
const processedFileContents: any[] = []
if (fileAttachments && fileAttachments.length > 0) {
logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`)
// logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`)
for (const attachment of fileAttachments) {
try {
@@ -296,7 +296,7 @@ export async function POST(req: NextRequest) {
}
// Download file from S3
logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`)
// logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`)
let fileBuffer: Buffer
if (USE_S3_STORAGE) {
fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG)
@@ -309,9 +309,9 @@ export async function POST(req: NextRequest) {
const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type)
if (fileContent) {
processedFileContents.push(fileContent)
logger.info(
`[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})`
)
// logger.info(
// `[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})`
// )
}
} catch (error) {
logger.error(
@@ -424,27 +424,7 @@ export async function POST(req: NextRequest) {
...(requestOrigin ? { origin: requestOrigin } : {}),
}
// Log the payload being sent to the streaming endpoint
try {
logger.info(`[${tracker.requestId}] Sending payload to sim agent streaming endpoint`, {
url: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`,
provider: providerToUse,
mode,
stream,
workflowId,
hasConversationId: !!effectiveConversationId,
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
messagesCount: requestPayload.messages.length,
...(requestOrigin ? { origin: requestOrigin } : {}),
})
// Full payload as JSON string
logger.info(
`[${tracker.requestId}] Full streaming payload: ${JSON.stringify(requestPayload)}`
)
} catch (e) {
logger.warn(`[${tracker.requestId}] Failed to log payload preview for streaming endpoint`, e)
}
// Log the payload being sent to the streaming endpoint (logs currently disabled)
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, {
method: 'POST',
@@ -475,7 +455,7 @@ export async function POST(req: NextRequest) {
// If streaming is requested, forward the stream and update chat later
if (stream && simAgentResponse.body) {
logger.info(`[${tracker.requestId}] Streaming response from sim agent`)
// logger.info(`[${tracker.requestId}] Streaming response from sim agent`)
// Create user message to save
const userMessage = {
@@ -493,7 +473,7 @@ export async function POST(req: NextRequest) {
let assistantContent = ''
const toolCalls: any[] = []
let buffer = ''
let isFirstDone = true
const isFirstDone = true
let responseIdFromStart: string | undefined
let responseIdFromDone: string | undefined
// Track tool call progress to identify a safe done event
@@ -515,30 +495,30 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if needed
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
logger.info(`[${tracker.requestId}] Starting title generation with stream updates`, {
chatId: actualChatId,
hasTitle: !!currentChat?.title,
conversationLength: conversationHistory.length,
message: message.substring(0, 100) + (message.length > 100 ? '...' : ''),
})
// logger.info(`[${tracker.requestId}] Starting title generation with stream updates`, {
// chatId: actualChatId,
// hasTitle: !!currentChat?.title,
// conversationLength: conversationHistory.length,
// message: message.substring(0, 100) + (message.length > 100 ? '...' : ''),
// })
generateChatTitleAsync(actualChatId, message, tracker.requestId, controller).catch(
(error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
}
)
} else {
logger.debug(`[${tracker.requestId}] Skipping title generation`, {
chatId: actualChatId,
hasTitle: !!currentChat?.title,
conversationLength: conversationHistory.length,
reason: !actualChatId
? 'no chatId'
: currentChat?.title
? 'already has title'
: conversationHistory.length > 0
? 'not first message'
: 'unknown',
})
// logger.debug(`[${tracker.requestId}] Skipping title generation`, {
// chatId: actualChatId,
// hasTitle: !!currentChat?.title,
// conversationLength: conversationHistory.length,
// reason: !actualChatId
// ? 'no chatId'
// : currentChat?.title
// ? 'already has title'
// : conversationHistory.length > 0
// ? 'not first message'
// : 'unknown',
// })
}
// Forward the sim agent stream and capture assistant response
@@ -549,7 +529,7 @@ export async function POST(req: NextRequest) {
while (true) {
const { done, value } = await reader.read()
if (done) {
logger.info(`[${tracker.requestId}] Stream reading completed`)
// logger.info(`[${tracker.requestId}] Stream reading completed`)
break
}
@@ -559,9 +539,9 @@ export async function POST(req: NextRequest) {
controller.enqueue(value)
} catch (error) {
// Client disconnected - stop reading from sim agent
logger.info(
`[${tracker.requestId}] Client disconnected, stopping stream processing`
)
// logger.info(
// `[${tracker.requestId}] Client disconnected, stopping stream processing`
// )
reader.cancel() // Stop reading from sim agent
break
}
@@ -608,15 +588,15 @@ export async function POST(req: NextRequest) {
break
case 'tool_call':
logger.info(
`[${tracker.requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`,
{
id: event.data?.id,
name: event.data?.name,
arguments: event.data?.arguments,
blockIndex: event.data?._blockIndex,
}
)
// logger.info(
// `[${tracker.requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`,
// {
// id: event.data?.id,
// name: event.data?.name,
// arguments: event.data?.arguments,
// blockIndex: event.data?._blockIndex,
// }
// )
if (!event.data?.partial) {
toolCalls.push(event.data)
if (event.data?.id) {
@@ -625,30 +605,24 @@ export async function POST(req: NextRequest) {
}
break
case 'tool_execution':
logger.info(`[${tracker.requestId}] Tool execution started:`, {
toolCallId: event.toolCallId,
toolName: event.toolName,
status: event.status,
})
case 'tool_generating':
// logger.info(`[${tracker.requestId}] Tool generating:`, {
// toolCallId: event.toolCallId,
// toolName: event.toolName,
// })
if (event.toolCallId) {
if (event.status === 'completed') {
startedToolExecutionIds.add(event.toolCallId)
completedToolExecutionIds.add(event.toolCallId)
} else {
startedToolExecutionIds.add(event.toolCallId)
}
startedToolExecutionIds.add(event.toolCallId)
}
break
case 'tool_result':
logger.info(`[${tracker.requestId}] Tool result received:`, {
toolCallId: event.toolCallId,
toolName: event.toolName,
success: event.success,
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
resultSize: JSON.stringify(event.result).length,
})
// logger.info(`[${tracker.requestId}] Tool result received:`, {
// toolCallId: event.toolCallId,
// toolName: event.toolName,
// success: event.success,
// result: `${JSON.stringify(event.result).substring(0, 200)}...`,
// resultSize: JSON.stringify(event.result).length,
// })
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
@@ -669,9 +643,6 @@ export async function POST(req: NextRequest) {
case 'start':
if (event.data?.responseId) {
responseIdFromStart = event.data.responseId
logger.info(
`[${tracker.requestId}] Received start event with responseId: ${responseIdFromStart}`
)
}
break
@@ -679,9 +650,7 @@ export async function POST(req: NextRequest) {
if (event.data?.responseId) {
responseIdFromDone = event.data.responseId
lastDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
)
// Mark this done as safe only if no tool call is currently in progress or pending
const announced = announcedToolCallIds.size
const completed = completedToolExecutionIds.size
@@ -689,34 +658,14 @@ export async function POST(req: NextRequest) {
const hasToolInProgress = announced > completed || started > completed
if (!hasToolInProgress) {
lastSafeDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
)
} else {
logger.info(
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
)
}
}
if (isFirstDone) {
logger.info(
`[${tracker.requestId}] Initial AI response complete, tool count: ${toolCalls.length}`
)
isFirstDone = false
} else {
logger.info(`[${tracker.requestId}] Conversation round complete`)
}
break
case 'error':
logger.error(`[${tracker.requestId}] Stream error event:`, event.error)
break
default:
logger.debug(
`[${tracker.requestId}] Unknown event type: ${event.type}`,
event
)
}
} catch (e) {
// Enhanced error handling for large payloads and parsing issues

View File

@@ -0,0 +1,53 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/auth'
import { routeExecution } from '@/lib/copilot/tools/server/router'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ExecuteCopilotServerToolAPI')
const ExecuteSchema = z.object({
toolName: z.string(),
payload: z.unknown().optional(),
})
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const body = await req.json()
try {
const preview = JSON.stringify(body).slice(0, 300)
logger.debug(`[${tracker.requestId}] Incoming request body preview`, { preview })
} catch {}
const { toolName, payload } = ExecuteSchema.parse(body)
logger.info(`[${tracker.requestId}] Executing server tool`, { toolName })
const result = await routeExecution(toolName, payload)
try {
const resultPreview = JSON.stringify(result).slice(0, 300)
logger.debug(`[${tracker.requestId}] Server tool result preview`, { toolName, resultPreview })
} catch {}
return NextResponse.json({ success: true, result })
} catch (error) {
if (error instanceof z.ZodError) {
logger.debug(`[${tracker.requestId}] Zod validation error`, { issues: error.issues })
return createBadRequestResponse('Invalid request body for execute-copilot-server-tool')
}
logger.error(`[${tracker.requestId}] Failed to execute server tool:`, error)
return createInternalServerErrorResponse('Failed to execute server tool')
}
}

View File

@@ -1,761 +1,7 @@
/**
* Tests for copilot methods API route
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
import { describe, expect, it } from 'vitest'
describe('Copilot Methods API Route', () => {
const mockRedisGet = vi.fn()
const mockRedisSet = vi.fn()
const mockGetRedisClient = vi.fn()
const mockToolRegistryHas = vi.fn()
const mockToolRegistryGet = vi.fn()
const mockToolRegistryExecute = vi.fn()
const mockToolRegistryGetAvailableIds = vi.fn()
beforeEach(() => {
vi.resetModules()
setupCommonApiMocks()
mockCryptoUuid()
// Mock Redis client
const mockRedisClient = {
get: mockRedisGet,
set: mockRedisSet,
}
mockGetRedisClient.mockReturnValue(mockRedisClient)
mockRedisGet.mockResolvedValue(null)
mockRedisSet.mockResolvedValue('OK')
vi.doMock('@/lib/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
// Mock tool registry
const mockToolRegistry = {
has: mockToolRegistryHas,
get: mockToolRegistryGet,
execute: mockToolRegistryExecute,
getAvailableIds: mockToolRegistryGetAvailableIds,
}
mockToolRegistryHas.mockReturnValue(true)
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: false })
mockToolRegistryExecute.mockResolvedValue({ success: true, data: 'Tool executed successfully' })
mockToolRegistryGetAvailableIds.mockReturnValue(['test-tool', 'another-tool'])
vi.doMock('@/lib/copilot/tools/server-tools/registry', () => ({
copilotToolRegistry: mockToolRegistry,
}))
// Mock environment variables
vi.doMock('@/lib/env', () => ({
env: {
INTERNAL_API_SECRET: 'test-secret-key',
COPILOT_API_KEY: 'test-copilot-key',
},
}))
// Mock setTimeout for polling
vi.spyOn(global, 'setTimeout').mockImplementation((callback, _delay) => {
if (typeof callback === 'function') {
setImmediate(callback)
}
return setTimeout(() => {}, 0) as any
})
// Mock Date.now for timeout control
let mockTime = 1640995200000
vi.spyOn(Date, 'now').mockImplementation(() => {
mockTime += 1000 // Add 1 second each call
return mockTime
})
// Mock crypto.randomUUID for request IDs
vi.spyOn(crypto, 'randomUUID').mockReturnValue('test-request-id')
})
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('POST', () => {
it('should return 401 when API key is missing', async () => {
const req = createMockRequest('POST', {
methodId: 'test-tool',
params: {},
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({
success: false,
error: 'API key required',
})
})
it('should return 401 when API key is invalid', async () => {
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'invalid-key',
},
body: JSON.stringify({
methodId: 'test-tool',
params: {},
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(typeof responseData.error).toBe('string')
})
it('should return 401 when internal API key is not configured', async () => {
// Mock environment with no API key
vi.doMock('@/lib/env', () => ({
env: {
INTERNAL_API_SECRET: undefined,
COPILOT_API_KEY: 'test-copilot-key',
},
}))
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'any-key',
},
body: JSON.stringify({
methodId: 'test-tool',
params: {},
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData.status).toBeUndefined()
expect(responseData.success).toBe(false)
expect(typeof responseData.error).toBe('string')
})
it('should return 400 for invalid request body - missing methodId', async () => {
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
params: {},
// Missing methodId
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toContain('Required')
})
it('should return 400 for empty methodId', async () => {
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: '',
params: {},
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toContain('Method ID is required')
})
it('should return 400 when tool is not found in registry', async () => {
mockToolRegistryHas.mockReturnValue(false)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'unknown-tool',
params: {},
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toContain('Unknown method: unknown-tool')
expect(responseData.error).toContain('Available methods: test-tool, another-tool')
})
it('should successfully execute a tool without interruption', async () => {
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'test-tool',
params: { key: 'value' },
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
data: 'Tool executed successfully',
})
expect(mockToolRegistryExecute).toHaveBeenCalledWith('test-tool', { key: 'value' })
})
it('should handle tool execution with default empty params', async () => {
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'test-tool',
// No params provided
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
data: 'Tool executed successfully',
})
expect(mockToolRegistryExecute).toHaveBeenCalledWith('test-tool', {})
})
it('should return 400 when tool requires interrupt but no toolCallId provided', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
// No toolCallId provided
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe(
'This tool requires approval but no tool call ID was provided'
)
})
it('should handle tool execution with interrupt - user approval', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return accepted status immediately (simulate quick approval)
mockRedisGet.mockResolvedValue(
JSON.stringify({ status: 'accepted', message: 'User approved' })
)
// Reset Date.now mock to not trigger timeout
let mockTime = 1640995200000
vi.spyOn(Date, 'now').mockImplementation(() => {
mockTime += 100 // Small increment to avoid timeout
return mockTime
})
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: { key: 'value' },
toolCallId: 'tool-call-123',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
data: 'Tool executed successfully',
})
// Verify Redis operations
expect(mockRedisSet).toHaveBeenCalledWith(
'tool_call:tool-call-123',
expect.stringContaining('"status":"pending"'),
'EX',
86400
)
expect(mockRedisGet).toHaveBeenCalledWith('tool_call:tool-call-123')
expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', {
key: 'value',
confirmationMessage: 'User approved',
fullData: {
message: 'User approved',
status: 'accepted',
},
})
})
it('should handle tool execution with interrupt - user rejection', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return rejected status
mockRedisGet.mockResolvedValue(
JSON.stringify({ status: 'rejected', message: 'User rejected' })
)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-456',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200) // User rejection returns 200
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe(
'The user decided to skip running this tool. This was a user decision.'
)
// Tool should not be executed when rejected
expect(mockToolRegistryExecute).not.toHaveBeenCalled()
})
it('should handle tool execution with interrupt - error status', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return error status
mockRedisGet.mockResolvedValue(
JSON.stringify({ status: 'error', message: 'Tool execution failed' })
)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-error',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe('Tool execution failed')
})
it('should handle tool execution with interrupt - background status', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return background status
mockRedisGet.mockResolvedValue(
JSON.stringify({ status: 'background', message: 'Running in background' })
)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-bg',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
data: 'Tool executed successfully',
})
expect(mockToolRegistryExecute).toHaveBeenCalled()
})
it('should handle tool execution with interrupt - success status', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return success status
mockRedisGet.mockResolvedValue(
JSON.stringify({ status: 'success', message: 'Completed successfully' })
)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-success',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
data: 'Tool executed successfully',
})
expect(mockToolRegistryExecute).toHaveBeenCalled()
})
it('should handle tool execution with interrupt - timeout', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to never return a status (timeout scenario)
mockRedisGet.mockResolvedValue(null)
// Mock Date.now to trigger timeout quickly
let mockTime = 1640995200000
vi.spyOn(Date, 'now').mockImplementation(() => {
mockTime += 100000 // Add 100 seconds each call to trigger timeout
return mockTime
})
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-timeout',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(408) // Request Timeout
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe('Tool execution request timed out')
expect(mockToolRegistryExecute).not.toHaveBeenCalled()
})
it('should handle unexpected status in interrupt flow', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return unexpected status
mockRedisGet.mockResolvedValue(
JSON.stringify({ status: 'unknown-status', message: 'Unknown' })
)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-unknown',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe('Unexpected tool call status: unknown-status')
})
it('should handle Redis client unavailable for interrupt flow', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
mockGetRedisClient.mockReturnValue(null)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-no-redis',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(408) // Timeout due to Redis unavailable
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe('Tool execution request timed out')
})
it('should handle no_op tool with confirmation message', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return accepted status with message
mockRedisGet.mockResolvedValue(
JSON.stringify({ status: 'accepted', message: 'Confirmation message' })
)
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'no_op',
params: { existing: 'param' },
toolCallId: 'tool-call-noop',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200)
// Verify confirmation message was added to params
expect(mockToolRegistryExecute).toHaveBeenCalledWith('no_op', {
existing: 'param',
confirmationMessage: 'Confirmation message',
fullData: {
message: 'Confirmation message',
status: 'accepted',
},
})
})
it('should handle Redis errors in interrupt flow', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to throw an error
mockRedisGet.mockRejectedValue(new Error('Redis connection failed'))
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-redis-error',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(408) // Timeout due to Redis error
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe('Tool execution request timed out')
})
it('should handle tool execution failure', async () => {
mockToolRegistryExecute.mockResolvedValue({
success: false,
error: 'Tool execution failed',
})
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'failing-tool',
params: {},
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200) // Still returns 200, but with success: false
const responseData = await response.json()
expect(responseData).toEqual({
success: false,
error: 'Tool execution failed',
})
})
it('should handle JSON parsing errors in request body', async () => {
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: '{invalid-json',
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toContain('JSON')
})
it('should handle tool registry execution throwing an error', async () => {
mockToolRegistryExecute.mockRejectedValue(new Error('Registry execution failed'))
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'error-tool',
params: {},
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.success).toBe(false)
expect(responseData.error).toBe('Registry execution failed')
})
it('should handle old format Redis status (string instead of JSON)', async () => {
mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true })
// Mock Redis to return old format (direct status string)
mockRedisGet.mockResolvedValue('accepted')
const req = new NextRequest('http://localhost:3000/api/copilot/methods', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-secret-key',
},
body: JSON.stringify({
methodId: 'interrupt-tool',
params: {},
toolCallId: 'tool-call-old-format',
}),
})
const { POST } = await import('@/app/api/copilot/methods/route')
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
data: 'Tool executed successfully',
})
expect(mockToolRegistryExecute).toHaveBeenCalled()
})
describe('copilot methods route placeholder', () => {
it('loads test suite', () => {
expect(true).toBe(true)
})
})

View File

@@ -1,395 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
import type { NotificationStatus } from '@/lib/copilot/types'
import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { getRedisClient } from '@/lib/redis'
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
const logger = createLogger('CopilotMethodsAPI')
/**
* Add a tool call to Redis with 'pending' status
*/
async function addToolToRedis(toolCallId: string): Promise<void> {
if (!toolCallId) {
logger.warn('addToolToRedis: No tool call ID provided')
return
}
const redis = getRedisClient()
if (!redis) {
logger.warn('addToolToRedis: Redis client not available')
return
}
try {
const key = `tool_call:${toolCallId}`
const status: NotificationStatus = 'pending'
// Store as JSON object for consistency with confirm API
const toolCallData = {
status,
message: null,
timestamp: new Date().toISOString(),
}
// Set with 24 hour expiry (86400 seconds)
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400)
logger.info('Tool call added to Redis', {
toolCallId,
key,
status,
})
} catch (error) {
logger.error('Failed to add tool call to Redis', {
toolCallId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
/**
* Poll Redis for tool call status updates
* Returns when status changes to 'Accepted' or 'Rejected', or times out after 60 seconds
*/
async function pollRedisForTool(
toolCallId: string
): Promise<{ status: NotificationStatus; message?: string; fullData?: any } | null> {
const redis = getRedisClient()
if (!redis) {
logger.warn('pollRedisForTool: Redis client not available')
return null
}
const key = `tool_call:${toolCallId}`
const timeout = 600000 // 10 minutes for long-running operations
const pollInterval = 1000 // 1 second
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
try {
const redisValue = await redis.get(key)
if (!redisValue) {
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
let status: NotificationStatus | null = null
let message: string | undefined
let fullData: any = null
// Try to parse as JSON (new format), fallback to string (old format)
try {
const parsedData = JSON.parse(redisValue)
status = parsedData.status as NotificationStatus
message = parsedData.message || undefined
fullData = parsedData // Store the full parsed data
} catch {
// Fallback to old format (direct status string)
status = redisValue as NotificationStatus
}
if (status !== 'pending') {
// Log the message found in redis prominently - always log, even if message is null/undefined
logger.info('Redis poller found non-pending status', {
toolCallId,
foundMessage: message,
messageType: typeof message,
messageIsNull: message === null,
messageIsUndefined: message === undefined,
status,
duration: Date.now() - startTime,
rawRedisValue: redisValue,
})
// Special logging for set environment variables tool when Redis status is found
if (toolCallId && (status === 'accepted' || status === 'rejected')) {
logger.info('SET_ENV_VARS: Redis polling found status update', {
toolCallId,
foundStatus: status,
redisMessage: message,
pollDuration: Date.now() - startTime,
redisKey: `tool_call:${toolCallId}`,
})
}
return { status, message, fullData }
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, pollInterval))
} catch (error) {
logger.error('Error polling Redis for tool call status', {
toolCallId,
error: error instanceof Error ? error.message : 'Unknown error',
})
return null
}
}
logger.warn('Tool call polling timed out', {
toolCallId,
timeout,
})
return null
}
/**
* Handle tool calls that require user interruption/approval
* Returns { approved: boolean, rejected: boolean, error?: boolean, message?: string } to distinguish between rejection, timeout, and error
*/
async function interruptHandler(toolCallId: string): Promise<{
approved: boolean
rejected: boolean
error?: boolean
message?: string
fullData?: any
}> {
if (!toolCallId) {
logger.error('interruptHandler: No tool call ID provided')
return { approved: false, rejected: false, error: true, message: 'No tool call ID provided' }
}
logger.info('Starting interrupt handler for tool call', { toolCallId })
try {
// Step 1: Add tool to Redis with 'pending' status
await addToolToRedis(toolCallId)
// Step 2: Poll Redis for status update
const result = await pollRedisForTool(toolCallId)
if (!result) {
logger.error('Failed to get tool call status or timed out', { toolCallId })
return { approved: false, rejected: false }
}
const { status, message, fullData } = result
if (status === 'rejected') {
logger.info('Tool execution rejected by user', { toolCallId, message })
return { approved: false, rejected: true, message, fullData }
}
if (status === 'accepted') {
logger.info('Tool execution approved by user', { toolCallId, message })
return { approved: true, rejected: false, message, fullData }
}
if (status === 'error') {
logger.error('Tool execution failed with error', { toolCallId, message })
return { approved: false, rejected: false, error: true, message, fullData }
}
if (status === 'background') {
logger.info('Tool execution moved to background', { toolCallId, message })
return { approved: true, rejected: false, message, fullData }
}
if (status === 'success') {
logger.info('Tool execution completed successfully', { toolCallId, message })
return { approved: true, rejected: false, message, fullData }
}
logger.warn('Unexpected tool call status', { toolCallId, status, message })
return {
approved: false,
rejected: false,
error: true,
message: `Unexpected tool call status: ${status}`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Error in interrupt handler', {
toolCallId,
error: errorMessage,
})
return {
approved: false,
rejected: false,
error: true,
message: `Interrupt handler error: ${errorMessage}`,
}
}
}
const MethodExecutionSchema = z.object({
methodId: z.string().min(1, 'Method ID is required'),
params: z.record(z.any()).optional().default({}),
toolCallId: z.string().nullable().optional().default(null),
})
/**
* POST /api/copilot/methods
* Execute a method based on methodId with internal API key auth
*/
export async function POST(req: NextRequest) {
const requestId = crypto.randomUUID()
const startTime = Date.now()
try {
// Evaluate both auth schemes; pass if either is valid
const internalAuth = checkInternalApiKey(req)
const copilotAuth = checkCopilotApiKey(req)
const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success)
if (!isAuthenticated) {
const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed'
return NextResponse.json(createErrorResponse(errorMessage), {
status: 401,
})
}
const body = await req.json()
const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body)
logger.info(`[${requestId}] Method execution request`, {
methodId,
toolCallId,
hasParams: !!params && Object.keys(params).length > 0,
})
// Check if tool exists in registry
if (!copilotToolRegistry.has(methodId)) {
logger.error(`[${requestId}] Tool not found in registry: ${methodId}`, {
methodId,
toolCallId,
availableTools: copilotToolRegistry.getAvailableIds(),
registrySize: copilotToolRegistry.getAvailableIds().length,
})
return NextResponse.json(
createErrorResponse(
`Unknown method: ${methodId}. Available methods: ${copilotToolRegistry.getAvailableIds().join(', ')}`
),
{ status: 400 }
)
}
logger.info(`[${requestId}] Tool found in registry: ${methodId}`, {
toolCallId,
})
// Check if the tool requires interrupt/approval
const tool = copilotToolRegistry.get(methodId)
if (tool?.requiresInterrupt) {
if (!toolCallId) {
logger.warn(`[${requestId}] Tool requires interrupt but no toolCallId provided`, {
methodId,
})
return NextResponse.json(
createErrorResponse('This tool requires approval but no tool call ID was provided'),
{ status: 400 }
)
}
logger.info(`[${requestId}] Tool requires interrupt, starting approval process`, {
methodId,
toolCallId,
})
// Handle interrupt flow
const { approved, rejected, error, message, fullData } = await interruptHandler(toolCallId)
if (rejected) {
logger.info(`[${requestId}] Tool execution rejected by user`, {
methodId,
toolCallId,
message,
})
return NextResponse.json(
createErrorResponse(
'The user decided to skip running this tool. This was a user decision.'
),
{ status: 200 } // Changed to 200 - user rejection is a valid response
)
}
if (error) {
logger.error(`[${requestId}] Tool execution failed with error`, {
methodId,
toolCallId,
message,
})
return NextResponse.json(
createErrorResponse(message || 'Tool execution failed with unknown error'),
{ status: 500 } // 500 Internal Server Error
)
}
if (!approved) {
logger.warn(`[${requestId}] Tool execution timed out`, {
methodId,
toolCallId,
})
return NextResponse.json(
createErrorResponse('Tool execution request timed out'),
{ status: 408 } // 408 Request Timeout
)
}
logger.info(`[${requestId}] Tool execution approved by user`, {
methodId,
toolCallId,
message,
})
// For tools that need confirmation data, pass the message and/or fullData as parameters
if (message) {
params.confirmationMessage = message
}
if (fullData) {
params.fullData = fullData
}
}
// Execute the tool directly via registry
const result = await copilotToolRegistry.execute(methodId, params)
logger.info(`[${requestId}] Tool execution result:`, {
methodId,
toolCallId,
success: result.success,
hasData: !!result.data,
hasError: !!result.error,
})
const duration = Date.now() - startTime
logger.info(`[${requestId}] Method execution completed: ${methodId}`, {
methodId,
toolCallId,
duration,
success: result.success,
})
return NextResponse.json(result)
} catch (error) {
const duration = Date.now() - startTime
if (error instanceof z.ZodError) {
logger.error(`[${requestId}] Request validation error:`, {
duration,
errors: error.errors,
})
return NextResponse.json(
createErrorResponse(
`Invalid request data: ${error.errors.map((e) => e.message).join(', ')}`
),
{ status: 400 }
)
}
logger.error(`[${requestId}] Unexpected error:`, {
duration,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
createErrorResponse(error instanceof Error ? error.message : 'Internal server error'),
{ status: 500 }
)
}
}

View File

@@ -1,14 +0,0 @@
import type { CopilotToolResponse } from '@/lib/copilot/tools/server-tools/base'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotMethodsUtils')
/**
* Create a standardized error response
*/
export function createErrorResponse(error: string): CopilotToolResponse {
return {
success: false,
error,
}
}

View File

@@ -0,0 +1,125 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
const logger = createLogger('CopilotMarkToolCompleteAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
// Schema for mark-complete request
const MarkCompleteSchema = z.object({
id: z.string(),
name: z.string(),
status: z.number().int(),
message: z.any().optional(),
data: z.any().optional(),
})
/**
* POST /api/copilot/tools/mark-complete
* Proxy to Sim Agent: POST /api/tools/mark-complete
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const body = await req.json()
// Log raw body shape for diagnostics (avoid dumping huge payloads)
try {
const bodyPreview = JSON.stringify(body).slice(0, 300)
logger.debug(`[${tracker.requestId}] Incoming mark-complete raw body preview`, {
preview: `${bodyPreview}${bodyPreview.length === 300 ? '...' : ''}`,
})
} catch {}
const parsed = MarkCompleteSchema.parse(body)
const messagePreview = (() => {
try {
const s =
typeof parsed.message === 'string' ? parsed.message : JSON.stringify(parsed.message)
return s ? `${s.slice(0, 200)}${s.length > 200 ? '...' : ''}` : undefined
} catch {
return undefined
}
})()
logger.info(`[${tracker.requestId}] Forwarding tool mark-complete`, {
userId,
toolCallId: parsed.id,
toolName: parsed.name,
status: parsed.status,
hasMessage: parsed.message !== undefined,
hasData: parsed.data !== undefined,
messagePreview,
agentUrl: `${SIM_AGENT_API_URL}/api/tools/mark-complete`,
})
const agentRes = await fetch(`${SIM_AGENT_API_URL}/api/tools/mark-complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify(parsed),
})
// Attempt to parse agent response JSON
let agentJson: any = null
let agentText: string | null = null
try {
agentJson = await agentRes.json()
} catch (_) {
try {
agentText = await agentRes.text()
} catch {}
}
logger.info(`[${tracker.requestId}] Agent responded to mark-complete`, {
status: agentRes.status,
ok: agentRes.ok,
responseJsonPreview: agentJson ? JSON.stringify(agentJson).slice(0, 300) : undefined,
responseTextPreview: agentText ? agentText.slice(0, 300) : undefined,
})
if (agentRes.ok) {
return NextResponse.json({ success: true })
}
const errorMessage =
agentJson?.error || agentText || `Agent responded with status ${agentRes.status}`
const status = agentRes.status >= 500 ? 500 : 400
logger.warn(`[${tracker.requestId}] Mark-complete failed`, {
status,
error: errorMessage,
})
return NextResponse.json({ success: false, error: errorMessage }, { status })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${tracker.requestId}] Invalid mark-complete request body`, {
issues: error.issues,
})
return createBadRequestResponse('Invalid request body for mark-complete')
}
logger.error(`[${tracker.requestId}] Failed to proxy mark-complete:`, error)
return createInternalServerErrorResponse('Failed to mark tool as complete')
}
}

View File

@@ -213,24 +213,81 @@ function createUserFriendlyErrorMessage(
}
/**
* Resolves environment variables and tags in code
* @param code - Code with variables
* @param params - Parameters that may contain variable values
* @param envVars - Environment variables from the workflow
* @returns Resolved code
* Resolves workflow variables with <variable.name> syntax
*/
function resolveWorkflowVariables(
code: string,
workflowVariables: Record<string, any>,
contextVariables: Record<string, any>
): string {
let resolvedCode = code
function resolveCodeVariables(
const variableMatches = resolvedCode.match(/<variable\.([^>]+)>/g) || []
for (const match of variableMatches) {
const variableName = match.slice('<variable.'.length, -1).trim()
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
const foundVariable = Object.entries(workflowVariables).find(
([_, variable]) => (variable.name || '').replace(/\s+/g, '') === variableName
)
if (foundVariable) {
const variable = foundVariable[1]
// Get the typed value - handle different variable types
let variableValue = variable.value
if (variable.value !== undefined && variable.value !== null) {
try {
// Handle 'string' type the same as 'plain' for backward compatibility
const type = variable.type === 'string' ? 'plain' : variable.type
// For plain text, use exactly what's entered without modifications
if (type === 'plain' && typeof variableValue === 'string') {
// Use as-is for plain text
} else if (type === 'number') {
variableValue = Number(variableValue)
} else if (type === 'boolean') {
variableValue = variableValue === 'true' || variableValue === true
} else if (type === 'json') {
try {
variableValue =
typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue
} catch {
// Keep original value if JSON parsing fails
}
}
} catch (error) {
// Fallback to original value on error
variableValue = variable.value
}
}
// Create a safe variable reference
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = variableValue
// Replace the variable reference with the safe variable name
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
} else {
// Variable not found - replace with empty string to avoid syntax errors
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), '')
}
}
return resolvedCode
}
/**
* Resolves environment variables with {{var_name}} syntax
*/
function resolveEnvironmentVariables(
code: string,
params: Record<string, any>,
envVars: Record<string, string> = {},
blockData: Record<string, any> = {},
blockNameMapping: Record<string, string> = {}
): { resolvedCode: string; contextVariables: Record<string, any> } {
envVars: Record<string, string>,
contextVariables: Record<string, any>
): string {
let resolvedCode = code
const contextVariables: Record<string, any> = {}
// Resolve environment variables with {{var_name}} syntax
const envVarMatches = resolvedCode.match(/\{\{([^}]+)\}\}/g) || []
for (const match of envVarMatches) {
const varName = match.slice(2, -2).trim()
@@ -245,7 +302,21 @@ function resolveCodeVariables(
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
return resolvedCode
}
/**
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
*/
function resolveTagVariables(
code: string,
params: Record<string, any>,
blockData: Record<string, any>,
blockNameMapping: Record<string, string>,
contextVariables: Record<string, any>
): string {
let resolvedCode = code
const tagMatches = resolvedCode.match(/<([a-zA-Z_][a-zA-Z0-9_.]*[a-zA-Z0-9_])>/g) || []
for (const match of tagMatches) {
@@ -300,6 +371,42 @@ function resolveCodeVariables(
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
return resolvedCode
}
/**
* Resolves environment variables and tags in code
* @param code - Code with variables
* @param params - Parameters that may contain variable values
* @param envVars - Environment variables from the workflow
* @returns Resolved code
*/
function resolveCodeVariables(
code: string,
params: Record<string, any>,
envVars: Record<string, string> = {},
blockData: Record<string, any> = {},
blockNameMapping: Record<string, string> = {},
workflowVariables: Record<string, any> = {}
): { resolvedCode: string; contextVariables: Record<string, any> } {
let resolvedCode = code
const contextVariables: Record<string, any> = {}
// Resolve workflow variables with <variable.name> syntax first
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
// Resolve environment variables with {{var_name}} syntax
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
resolvedCode = resolveTagVariables(
resolvedCode,
params,
blockData,
blockNameMapping,
contextVariables
)
return { resolvedCode, contextVariables }
}
@@ -338,6 +445,7 @@ export async function POST(req: NextRequest) {
envVars = {},
blockData = {},
blockNameMapping = {},
workflowVariables = {},
workflowId,
isCustomTool = false,
} = body
@@ -360,7 +468,8 @@ export async function POST(req: NextRequest) {
executionParams,
envVars,
blockData,
blockNameMapping
blockNameMapping,
workflowVariables
)
resolvedCode = codeResolution.resolvedCode
const contextVariables = codeResolution.contextVariables
@@ -368,8 +477,8 @@ export async function POST(req: NextRequest) {
const executionMethod = 'vm' // Default execution method
logger.info(`[${requestId}] Using VM for code execution`, {
resolvedCode,
hasEnvVars: Object.keys(envVars).length > 0,
hasWorkflowVariables: Object.keys(workflowVariables).length > 0,
})
// Create a secure context with console logging

View File

@@ -3,6 +3,7 @@ import { z } from 'zod'
import { renderHelpConfirmationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
@@ -95,7 +96,7 @@ ${message}
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
subject: `[${type.toUpperCase()}] ${subject}`,
text: emailText,
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
from: getFromEmailAddress(),
replyTo: email,
emailType: 'transactional',
attachments: images.map((image) => ({
@@ -125,7 +126,7 @@ ${message}
to: [email],
subject: `Your ${type} request has been received: ${subject}`,
html: confirmationHtml,
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
from: getFromEmailAddress(),
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
emailType: 'transactional',
})

View File

@@ -73,30 +73,59 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
// Conditionally select columns based on detail level to optimize performance
const selectColumns =
params.details === 'full'
? {
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData, // Large field - only in full mode
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files, // Large field - only in full mode
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowColor: workflow.color,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
}
: {
// Basic mode - exclude large fields for better performance
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: sql<null>`NULL`, // Exclude large execution data in basic mode
cost: workflowExecutionLogs.cost,
files: sql<null>`NULL`, // Exclude files in basic mode
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowColor: workflow.color,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
}
const baseQuery = db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowColor: workflow.color,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
})
.select(selectColumns)
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
@@ -276,18 +305,24 @@ export async function GET(request: NextRequest) {
const enhancedLogs = logs.map((log) => {
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
// Use stored trace spans if available, otherwise create from block executions
const storedTraceSpans = (log.executionData as any)?.traceSpans
const traceSpans =
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
? storedTraceSpans
: createTraceSpans(blockExecutions)
// Only process trace spans and detailed cost in full mode
let traceSpans = []
let costSummary = (log.cost as any) || { total: 0 }
// Prefer stored cost JSON; otherwise synthesize from blocks
const costSummary =
log.cost && Object.keys(log.cost as any).length > 0
? (log.cost as any)
: extractCostSummary(blockExecutions)
if (params.details === 'full' && log.executionData) {
// Use stored trace spans if available, otherwise create from block executions
const storedTraceSpans = (log.executionData as any)?.traceSpans
traceSpans =
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
? storedTraceSpans
: createTraceSpans(blockExecutions)
// Prefer stored cost JSON; otherwise synthesize from blocks
costSummary =
log.cost && Object.keys(log.cost as any).length > 0
? (log.cost as any)
: extractCostSummary(blockExecutions)
}
const workflowSummary = {
id: log.workflowId,

View File

@@ -1,6 +1,7 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, user, userStats } from '@/db/schema'
@@ -80,8 +81,6 @@ export async function GET(
.select({
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
usageLimitSetBy: userStats.usageLimitSetBy,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
lastPeriodCost: userStats.lastPeriodCost,
@@ -90,11 +89,22 @@ export async function GET(
.where(eq(userStats.userId, memberId))
.limit(1)
const computed = await getUserUsageData(memberId)
if (usageData.length > 0) {
memberData = {
...memberData,
usage: usageData[0],
} as typeof memberData & { usage: (typeof usageData)[0] }
usage: {
...usageData[0],
billingPeriodStart: computed.billingPeriodStart,
billingPeriodEnd: computed.billingPeriodEnd,
},
} as typeof memberData & {
usage: (typeof usageData)[0] & {
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
}
}
}
}

View File

@@ -3,6 +3,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { sendEmail } from '@/lib/email/mailer'
import { quickValidateEmail } from '@/lib/email/validation'
@@ -63,7 +64,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Include usage data if requested and user has admin access
if (includeUsage && hasAdminAccess) {
const membersWithUsage = await db
const base = await db
.select({
id: member.id,
userId: member.userId,
@@ -74,8 +75,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
usageLimitSetBy: userStats.usageLimitSetBy,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
})
@@ -84,6 +83,17 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.leftJoin(userStats, eq(user.id, userStats.userId))
.where(eq(member.organizationId, organizationId))
const membersWithUsage = await Promise.all(
base.map(async (row) => {
const usage = await getUserUsageData(row.userId)
return {
...row,
billingPeriodStart: usage.billingPeriodStart,
billingPeriodEnd: usage.billingPeriodEnd,
}
})
)
return NextResponse.json({
success: true,
data: membersWithUsage,

View File

@@ -39,6 +39,9 @@ export async function POST(request: NextRequest) {
stream,
messages,
environmentVariables,
workflowVariables,
blockData,
blockNameMapping,
reasoningEffort,
verbosity,
} = body
@@ -60,6 +63,7 @@ export async function POST(request: NextRequest) {
messageCount: messages?.length || 0,
hasEnvironmentVariables:
!!environmentVariables && Object.keys(environmentVariables).length > 0,
hasWorkflowVariables: !!workflowVariables && Object.keys(workflowVariables).length > 0,
reasoningEffort,
verbosity,
})
@@ -103,6 +107,9 @@ export async function POST(request: NextRequest) {
stream,
messages,
environmentVariables,
workflowVariables,
blockData,
blockNameMapping,
reasoningEffort,
verbosity,
})

View File

@@ -474,8 +474,10 @@ export async function GET() {
})
await loggingSession.safeCompleteWithError({
message: `Schedule execution failed before workflow started: ${earlyError.message}`,
stackTrace: earlyError.stack,
error: {
message: `Schedule execution failed before workflow started: ${earlyError.message}`,
stackTrace: earlyError.stack,
},
})
} catch (loggingError) {
logger.error(
@@ -591,8 +593,10 @@ export async function GET() {
})
await failureLoggingSession.safeCompleteWithError({
message: `Schedule execution failed: ${error.message}`,
stackTrace: error.stack,
error: {
message: `Schedule execution failed: ${error.message}`,
stackTrace: error.stack,
},
})
} catch (loggingError) {
logger.error(

View File

@@ -1,9 +1,11 @@
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { db } from '@/db'
import { templates } from '@/db/schema'
import { templates, workflow } from '@/db/schema'
const logger = createLogger('TemplateByIdAPI')
@@ -62,3 +64,153 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const updateTemplateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().min(1).max(500),
author: z.string().min(1).max(100),
category: z.string().min(1),
icon: z.string().min(1),
color: z.string().regex(/^#[0-9A-F]{6}$/i),
state: z.any().optional(), // Workflow state
})
// PUT /api/templates/[id] - Update a template
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validationResult = updateTemplateSchema.safeParse(body)
if (!validationResult.success) {
logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error)
return NextResponse.json(
{ error: 'Invalid template data', details: validationResult.error.errors },
{ status: 400 }
)
}
const { name, description, author, category, icon, color, state } = validationResult.data
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for update: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Permission: template owner OR admin of the workflow's workspace (if any)
let canUpdate = existingTemplate[0].userId === session.user.id
if (!canUpdate && existingTemplate[0].workflowId) {
const wfRows = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, existingTemplate[0].workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
if (workspaceId) {
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
if (hasAdmin) canUpdate = true
}
}
if (!canUpdate) {
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Update the template
const updatedTemplate = await db
.update(templates)
.set({
name,
description,
author,
category,
icon,
color,
...(state && { state }),
updatedAt: new Date(),
})
.where(eq(templates.id, id))
.returning()
logger.info(`[${requestId}] Successfully updated template: ${id}`)
return NextResponse.json({
data: updatedTemplate[0],
message: 'Template updated successfully',
})
} catch (error: any) {
logger.error(`[${requestId}] Error updating template: ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// DELETE /api/templates/[id] - Delete a template
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = crypto.randomUUID().slice(0, 8)
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch template
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existing.length === 0) {
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
const template = existing[0]
// Permission: owner or admin of the workflow's workspace (if any)
let canDelete = template.userId === session.user.id
if (!canDelete && template.workflowId) {
// Look up workflow to get workspaceId
const wfRows = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, template.workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
if (workspaceId) {
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
if (hasAdmin) canDelete = true
}
}
if (!canDelete) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
await db.delete(templates).where(eq(templates.id, id))
logger.info(`[${requestId}] Deleted template: ${id}`)
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting template: ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -77,6 +77,7 @@ const QueryParamsSchema = z.object({
limit: z.coerce.number().optional().default(50),
offset: z.coerce.number().optional().default(0),
search: z.string().optional(),
workflowId: z.string().optional(),
})
// GET /api/templates - Retrieve templates
@@ -111,6 +112,11 @@ export async function GET(request: NextRequest) {
)
}
// Apply workflow filter if provided (for getting template by workflow)
if (params.workflowId) {
conditions.push(eq(templates.workflowId, params.workflowId))
}
// Combine conditions
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined

View File

@@ -0,0 +1,67 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
table: z.string().min(1, 'Table name is required'),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = DeleteSchema.parse(body)
logger.info(
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildDeleteQuery(params.table, params.where)
const result = await executeQuery(connection, query, values)
logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`)
return NextResponse.json({
message: `Data deleted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL delete failed:`, error)
return NextResponse.json({ error: `MySQL delete failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,75 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLExecuteAPI')
const ExecuteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)
logger.info(
`[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}`
)
// Validate query before execution
const validation = validateQuery(params.query)
if (!validation.isValid) {
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(connection, params.query)
logger.info(`[${requestId}] SQL executed successfully, ${result.rowCount} row(s) affected`)
return NextResponse.json({
message: `SQL executed successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL execute failed:`, error)
return NextResponse.json({ error: `MySQL execute failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,91 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLInsertAPI')
const InsertSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error'
throw new Error(
`Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...`
)
}
}),
]),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
logger.info(`[${requestId}] Received data field type: ${typeof body.data}, value:`, body.data)
const params = InsertSchema.parse(body)
logger.info(
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildInsertQuery(params.table, params.data)
const result = await executeQuery(connection, query, values)
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
return NextResponse.json({
message: `Data inserted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL insert failed:`, error)
return NextResponse.json({ error: `MySQL insert failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,75 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLQueryAPI')
const QuerySchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = QuerySchema.parse(body)
logger.info(
`[${requestId}] Executing MySQL query on ${params.host}:${params.port}/${params.database}`
)
// Validate query before execution
const validation = validateQuery(params.query)
if (!validation.isValid) {
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(connection, params.query)
logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
return NextResponse.json({
message: `Query executed successfully. ${result.rowCount} row(s) returned.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL query failed:`, error)
return NextResponse.json({ error: `MySQL query failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,86 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLUpdateAPI')
const UpdateSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
throw new Error('Invalid JSON format in data field')
}
}),
]),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = UpdateSchema.parse(body)
logger.info(
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildUpdateQuery(params.table, params.data, params.where)
const result = await executeQuery(connection, query, values)
logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`)
return NextResponse.json({
message: `Data updated successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL update failed:`, error)
return NextResponse.json({ error: `MySQL update failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,159 @@
import mysql from 'mysql2/promise'
export interface MySQLConnectionConfig {
host: string
port: number
database: string
username: string
password: string
ssl?: string
}
export async function createMySQLConnection(config: MySQLConnectionConfig) {
const connectionConfig: mysql.ConnectionOptions = {
host: config.host,
port: config.port,
database: config.database,
user: config.username,
password: config.password,
}
// Handle SSL configuration
if (config.ssl === 'required') {
connectionConfig.ssl = { rejectUnauthorized: true }
} else if (config.ssl === 'preferred') {
connectionConfig.ssl = { rejectUnauthorized: false }
}
// For 'disabled', we don't set the ssl property at all
return mysql.createConnection(connectionConfig)
}
export async function executeQuery(
connection: mysql.Connection,
query: string,
values?: unknown[]
) {
const [rows, fields] = await connection.execute(query, values)
if (Array.isArray(rows)) {
return {
rows: rows as unknown[],
rowCount: rows.length,
fields,
}
}
return {
rows: [],
rowCount: (rows as mysql.ResultSetHeader).affectedRows || 0,
fields,
}
}
export function validateQuery(query: string): { isValid: boolean; error?: string } {
const trimmedQuery = query.trim().toLowerCase()
// Block dangerous SQL operations
const dangerousPatterns = [
/drop\s+database/i,
/drop\s+schema/i,
/drop\s+user/i,
/create\s+user/i,
/grant\s+/i,
/revoke\s+/i,
/alter\s+user/i,
/set\s+global/i,
/set\s+session/i,
/load\s+data/i,
/into\s+outfile/i,
/into\s+dumpfile/i,
/load_file\s*\(/i,
/system\s+/i,
/exec\s+/i,
/execute\s+immediate/i,
/xp_cmdshell/i,
/sp_configure/i,
/information_schema\.tables/i,
/mysql\.user/i,
/mysql\.db/i,
/mysql\.host/i,
/performance_schema/i,
/sys\./i,
]
for (const pattern of dangerousPatterns) {
if (pattern.test(query)) {
return {
isValid: false,
error: `Query contains potentially dangerous operation: ${pattern.source}`,
}
}
}
// Only allow specific statement types for execute endpoint
const allowedStatements = /^(select|insert|update|delete|with|show|describe|explain)\s+/i
if (!allowedStatements.test(trimmedQuery)) {
return {
isValid: false,
error:
'Only SELECT, INSERT, UPDATE, DELETE, WITH, SHOW, DESCRIBE, and EXPLAIN statements are allowed',
}
}
return { isValid: true }
}
export function buildInsertQuery(table: string, data: Record<string, unknown>) {
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const values = Object.values(data)
const placeholders = columns.map(() => '?').join(', ')
const query = `INSERT INTO ${sanitizedTable} (${columns.map(sanitizeIdentifier).join(', ')}) VALUES (${placeholders})`
return { query, values }
}
export function buildUpdateQuery(table: string, data: Record<string, unknown>, where: string) {
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const values = Object.values(data)
const setClause = columns.map((col) => `${sanitizeIdentifier(col)} = ?`).join(', ')
const query = `UPDATE ${sanitizedTable} SET ${setClause} WHERE ${where}`
return { query, values }
}
export function buildDeleteQuery(table: string, where: string) {
const sanitizedTable = sanitizeIdentifier(table)
const query = `DELETE FROM ${sanitizedTable} WHERE ${where}`
return { query, values: [] }
}
export function sanitizeIdentifier(identifier: string): string {
// Handle schema.table format
if (identifier.includes('.')) {
const parts = identifier.split('.')
return parts.map((part) => sanitizeSingleIdentifier(part)).join('.')
}
return sanitizeSingleIdentifier(identifier)
}
function sanitizeSingleIdentifier(identifier: string): string {
// Remove any existing backticks to prevent double-escaping
const cleaned = identifier.replace(/`/g, '')
// Validate identifier contains only safe characters
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) {
throw new Error(
`Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.`
)
}
// Wrap in backticks for MySQL
return `\`${cleaned}\``
}

View File

@@ -0,0 +1,74 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
buildDeleteQuery,
createPostgresConnection,
executeQuery,
} from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
table: z.string().min(1, 'Table name is required'),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = DeleteSchema.parse(body)
logger.info(
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const client = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildDeleteQuery(params.table, params.where)
const result = await executeQuery(client, query, values)
logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`)
return NextResponse.json({
message: `Data deleted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL delete failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL delete failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,82 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
createPostgresConnection,
executeQuery,
validateQuery,
} from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLExecuteAPI')
const ExecuteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)
logger.info(
`[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}`
)
// Validate query before execution
const validation = validateQuery(params.query)
if (!validation.isValid) {
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
const client = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(client, params.query)
logger.info(`[${requestId}] SQL executed successfully, ${result.rowCount} row(s) affected`)
return NextResponse.json({
message: `SQL executed successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL execute failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL execute failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,99 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
buildInsertQuery,
createPostgresConnection,
executeQuery,
} from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLInsertAPI')
const InsertSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error'
throw new Error(
`Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...`
)
}
}),
]),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
// Debug: Log the data field to see what we're getting
logger.info(`[${requestId}] Received data field type: ${typeof body.data}, value:`, body.data)
const params = InsertSchema.parse(body)
logger.info(
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const client = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildInsertQuery(params.table, params.data)
const result = await executeQuery(client, query, values)
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
return NextResponse.json({
message: `Data inserted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL insert failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL insert failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,65 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLQueryAPI')
const QuerySchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = QuerySchema.parse(body)
logger.info(
`[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}`
)
const client = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(client, params.query)
logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
return NextResponse.json({
message: `Query executed successfully. ${result.rowCount} row(s) returned.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL query failed:`, error)
return NextResponse.json({ error: `PostgreSQL query failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,93 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
buildUpdateQuery,
createPostgresConnection,
executeQuery,
} from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLUpdateAPI')
const UpdateSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('required'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
throw new Error('Invalid JSON format in data field')
}
}),
]),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = UpdateSchema.parse(body)
logger.info(
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const client = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildUpdateQuery(params.table, params.data, params.where)
const result = await executeQuery(client, query, values)
logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`)
return NextResponse.json({
message: `Data updated successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL update failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL update failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,173 @@
import { Client } from 'pg'
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
export async function createPostgresConnection(config: PostgresConnectionConfig): Promise<Client> {
const client = new Client({
host: config.host,
port: config.port,
database: config.database,
user: config.username,
password: config.password,
ssl:
config.ssl === 'disabled'
? false
: config.ssl === 'required'
? true
: config.ssl === 'preferred'
? { rejectUnauthorized: false }
: false,
connectionTimeoutMillis: 10000, // 10 seconds
query_timeout: 30000, // 30 seconds
})
try {
await client.connect()
return client
} catch (error) {
await client.end()
throw error
}
}
export async function executeQuery(
client: Client,
query: string,
params: unknown[] = []
): Promise<{ rows: unknown[]; rowCount: number }> {
const result = await client.query(query, params)
return {
rows: result.rows || [],
rowCount: result.rowCount || 0,
}
}
export function validateQuery(query: string): { isValid: boolean; error?: string } {
const trimmedQuery = query.trim().toLowerCase()
// Block dangerous SQL operations
const dangerousPatterns = [
/drop\s+database/i,
/drop\s+schema/i,
/drop\s+user/i,
/create\s+user/i,
/create\s+role/i,
/grant\s+/i,
/revoke\s+/i,
/alter\s+user/i,
/alter\s+role/i,
/set\s+role/i,
/reset\s+role/i,
/copy\s+.*from/i,
/copy\s+.*to/i,
/lo_import/i,
/lo_export/i,
/pg_read_file/i,
/pg_write_file/i,
/pg_ls_dir/i,
/information_schema\.tables/i,
/pg_catalog/i,
/pg_user/i,
/pg_shadow/i,
/pg_roles/i,
/pg_authid/i,
/pg_stat_activity/i,
/dblink/i,
/\\\\copy/i,
]
for (const pattern of dangerousPatterns) {
if (pattern.test(query)) {
return {
isValid: false,
error: `Query contains potentially dangerous operation: ${pattern.source}`,
}
}
}
// Only allow specific statement types for execute endpoint
const allowedStatements = /^(select|insert|update|delete|with|explain|analyze|show)\s+/i
if (!allowedStatements.test(trimmedQuery)) {
return {
isValid: false,
error:
'Only SELECT, INSERT, UPDATE, DELETE, WITH, EXPLAIN, ANALYZE, and SHOW statements are allowed',
}
}
return { isValid: true }
}
export function sanitizeIdentifier(identifier: string): string {
// Handle schema.table format
if (identifier.includes('.')) {
const parts = identifier.split('.')
return parts.map((part) => sanitizeSingleIdentifier(part)).join('.')
}
return sanitizeSingleIdentifier(identifier)
}
function sanitizeSingleIdentifier(identifier: string): string {
// Remove any existing double quotes to prevent double-escaping
const cleaned = identifier.replace(/"/g, '')
// Validate identifier contains only safe characters
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) {
throw new Error(
`Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.`
)
}
// Wrap in double quotes for PostgreSQL
return `"${cleaned}"`
}
export function buildInsertQuery(
table: string,
data: Record<string, unknown>
): {
query: string
values: unknown[]
} {
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col))
const placeholders = columns.map((_, index) => `$${index + 1}`)
const values = columns.map((col) => data[col])
const query = `INSERT INTO ${sanitizedTable} (${sanitizedColumns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`
return { query, values }
}
export function buildUpdateQuery(
table: string,
data: Record<string, unknown>,
where: string
): {
query: string
values: unknown[]
} {
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col))
const setClause = sanitizedColumns.map((col, index) => `${col} = $${index + 1}`).join(', ')
const values = columns.map((col) => data[col])
const query = `UPDATE ${sanitizedTable} SET ${setClause} WHERE ${where} RETURNING *`
return { query, values }
}
export function buildDeleteQuery(
table: string,
where: string
): {
query: string
values: unknown[]
} {
const sanitizedTable = sanitizeIdentifier(table)
const query = `DELETE FROM ${sanitizedTable} WHERE ${where} RETURNING *`
return { query, values: [] }
}

View File

@@ -95,12 +95,19 @@ export async function POST(req: NextRequest) {
{
stream,
historyLength: history.length,
endpoint: useWandAzure ? azureEndpoint : 'api.openai.com',
model: useWandAzure ? wandModelName : 'gpt-4o',
apiVersion: useWandAzure ? azureApiVersion : 'N/A',
}
)
// For streaming responses
if (stream) {
try {
logger.debug(
`[${requestId}] Starting streaming request to ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'}`
)
const streamCompletion = await client.chat.completions.create({
model: useWandAzure ? wandModelName : 'gpt-4o',
messages: messages,
@@ -109,6 +116,8 @@ export async function POST(req: NextRequest) {
stream: true,
})
logger.debug(`[${requestId}] Stream connection established successfully`)
return new Response(
new ReadableStream({
async start(controller) {
@@ -118,21 +127,23 @@ export async function POST(req: NextRequest) {
for await (const chunk of streamCompletion) {
const content = chunk.choices[0]?.delta?.content || ''
if (content) {
// Use the same format as codegen API for consistency
// Use SSE format identical to chat streaming
controller.enqueue(
encoder.encode(`${JSON.stringify({ chunk: content, done: false })}\n`)
encoder.encode(`data: ${JSON.stringify({ chunk: content })}\n\n`)
)
}
}
// Send completion signal
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: '', done: true })}\n`))
// Send completion signal in SSE format
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`))
controller.close()
logger.info(`[${requestId}] Wand generation streaming completed`)
} catch (streamError: any) {
logger.error(`[${requestId}] Streaming error`, { error: streamError.message })
controller.enqueue(
encoder.encode(`${JSON.stringify({ error: 'Streaming failed', done: true })}\n`)
encoder.encode(
`data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n`
)
)
controller.close()
}
@@ -140,9 +151,10 @@ export async function POST(req: NextRequest) {
}),
{
headers: {
'Content-Type': 'text/plain',
'Cache-Control': 'no-cache, no-transform',
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
}
)

View File

@@ -1,8 +1,10 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
@@ -242,6 +244,167 @@ export async function DELETE(
const foundWebhook = webhookData.webhook
// If it's an Airtable webhook, delete it from Airtable first
if (foundWebhook.provider === 'airtable') {
try {
const { baseId, externalId } = (foundWebhook.providerConfig || {}) as {
baseId?: string
externalId?: string
}
if (!baseId) {
logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion.`, {
webhookId: id,
})
return NextResponse.json(
{ error: 'Missing baseId for Airtable webhook deletion' },
{ status: 400 }
)
}
// Get access token for the workflow owner
const userIdForToken = webhookData.workflow.userId
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userIdForToken}. Cannot delete webhook in Airtable.`,
{ webhookId: id }
)
return NextResponse.json(
{ error: 'Airtable access token not found for webhook deletion' },
{ status: 401 }
)
}
// Resolve externalId if missing by listing webhooks and matching our notificationUrl
let resolvedExternalId: string | undefined = externalId
if (!resolvedExternalId) {
try {
const requestOrigin = new URL(request.url).origin
const effectiveOrigin = requestOrigin.includes('localhost')
? env.NEXT_PUBLIC_APP_URL || requestOrigin
: requestOrigin
const expectedNotificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${foundWebhook.path}`
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const listResp = await fetch(listUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
const listBody = await listResp.json().catch(() => null)
if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) {
const match = listBody.webhooks.find((w: any) => {
const url: string | undefined = w?.notificationUrl
if (!url) return false
// Prefer exact match; fallback to suffix match to handle origin/host remaps
return (
url === expectedNotificationUrl ||
url.endsWith(`/api/webhooks/trigger/${foundWebhook.path}`)
)
})
if (match?.id) {
resolvedExternalId = match.id as string
// Persist resolved externalId for future operations
try {
await db
.update(webhook)
.set({
providerConfig: {
...(foundWebhook.providerConfig || {}),
externalId: resolvedExternalId,
},
updatedAt: new Date(),
})
.where(eq(webhook.id, id))
} catch {
// non-fatal persistence error
}
logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, {
baseId,
externalId: resolvedExternalId,
})
} else {
logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, {
baseId,
expectedNotificationUrl,
})
}
} else {
logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, {
baseId,
status: listResp.status,
body: listBody,
})
}
} catch (e: any) {
logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, {
error: e?.message,
})
}
}
// If still not resolvable, skip remote deletion but proceed with local delete
if (!resolvedExternalId) {
logger.info(
`[${requestId}] Airtable externalId not found; skipping remote deletion and proceeding to remove local record`,
{ baseId }
)
}
if (resolvedExternalId) {
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
const airtableResponse = await fetch(airtableDeleteUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
// Attempt to parse error body for better diagnostics
if (!airtableResponse.ok) {
let responseBody: any = null
try {
responseBody = await airtableResponse.json()
} catch {
// ignore parse errors
}
logger.error(
`[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`,
{ baseId, externalId: resolvedExternalId, response: responseBody }
)
return NextResponse.json(
{
error: 'Failed to delete webhook from Airtable',
details:
(responseBody && (responseBody.error?.message || responseBody.error)) ||
`Status ${airtableResponse.status}`,
},
{ status: 500 }
)
}
logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, {
baseId,
externalId: resolvedExternalId,
})
}
} catch (error: any) {
logger.error(`[${requestId}] Error deleting Airtable webhook`, {
webhookId: id,
error: error.message,
stack: error.stack,
})
return NextResponse.json(
{ error: 'Failed to delete webhook from Airtable', details: error.message },
{ status: 500 }
)
}
}
// If it's a Telegram webhook, delete it from Telegram first
if (foundWebhook.provider === 'telegram') {
try {

View File

@@ -5,7 +5,22 @@ import { NextRequest } from 'next/server'
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
import {
createMockRequest,
mockExecutionDependencies,
mockTriggerDevSdk,
} from '@/app/api/__test-utils__/utils'
// Prefer mocking the background module to avoid loading Trigger.dev at all during tests
vi.mock('@/background/webhook-execution', () => ({
executeWebhookJob: vi.fn().mockResolvedValue({
success: true,
workflowId: 'test-workflow-id',
executionId: 'test-exec-id',
output: {},
executedAt: new Date().toISOString(),
}),
}))
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
@@ -111,6 +126,7 @@ describe('Webhook Trigger API Route', () => {
vi.resetAllMocks()
mockExecutionDependencies()
mockTriggerDevSdk()
vi.doMock('@/services/queue', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
@@ -309,11 +325,7 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
mockTriggerDevSdk()
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
@@ -339,11 +351,7 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
mockTriggerDevSdk()
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
@@ -369,11 +377,7 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
mockTriggerDevSdk()
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })

View File

@@ -2,12 +2,14 @@ import { tasks } from '@trigger.dev/sdk'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { env, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
handleSlackChallenge,
handleWhatsAppVerification,
validateMicrosoftTeamsSignature,
} from '@/lib/webhooks/utils'
import { executeWebhookJob } from '@/background/webhook-execution'
import { db } from '@/db'
import { subscription, webhook, workflow } from '@/db/schema'
import { RateLimiter } from '@/services/queue'
@@ -17,6 +19,7 @@ const logger = createLogger('WebhookTriggerAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 300
export const runtime = 'nodejs'
/**
* Webhook Verification Handler (GET)
@@ -330,10 +333,9 @@ export async function POST(
// Continue processing - better to risk usage limit bypass than fail webhook
}
// --- PHASE 5: Queue webhook execution via trigger.dev ---
// --- PHASE 5: Queue webhook execution (trigger.dev or direct based on env) ---
try {
// Queue the webhook execution task
const handle = await tasks.trigger('webhook-execution', {
const payload = {
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
userId: foundWorkflow.userId,
@@ -342,11 +344,24 @@ export async function POST(
headers: Object.fromEntries(request.headers.entries()),
path,
blockId: foundWebhook.blockId,
})
}
logger.info(
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
)
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (useTrigger) {
const handle = await tasks.trigger('webhook-execution', payload)
logger.info(
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
)
} else {
// Fire-and-forget direct execution to avoid blocking webhook response
void executeWebhookJob(payload).catch((error) => {
logger.error(`[${requestId}] Direct webhook execution failed`, error)
})
logger.info(
`[${requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
)
}
// Return immediate acknowledgment with provider-specific format
if (foundWebhook.provider === 'microsoftteams') {

View File

@@ -540,7 +540,7 @@ export async function POST(
)
}
// Rate limit passed - trigger the task
// Rate limit passed - always use Trigger.dev for async executions
const handle = await tasks.trigger('workflow-execution', {
workflowId,
userId: authenticatedUserId,

View File

@@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db'
import { apiKey as apiKeyTable, workflow } from '@/db/schema'
import { apiKey as apiKeyTable, templates, workflow } from '@/db/schema'
const logger = createLogger('WorkflowByIdAPI')
@@ -218,6 +218,48 @@ export async function DELETE(
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check if workflow has published templates before deletion
const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'
const deleteTemplatesParam = searchParams.get('deleteTemplates')
if (checkTemplates) {
// Return template information for frontend to handle
const publishedTemplates = await db
.select()
.from(templates)
.where(eq(templates.workflowId, workflowId))
return NextResponse.json({
hasPublishedTemplates: publishedTemplates.length > 0,
count: publishedTemplates.length,
publishedTemplates: publishedTemplates.map((t) => ({
id: t.id,
name: t.name,
views: t.views,
stars: t.stars,
})),
})
}
// Handle template deletion based on user choice
if (deleteTemplatesParam !== null) {
const deleteTemplates = deleteTemplatesParam === 'delete'
if (deleteTemplates) {
// Delete all templates associated with this workflow
await db.delete(templates).where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`)
} else {
// Orphan the templates (set workflowId to null)
await db
.update(templates)
.set({ workflowId: null })
.where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`)
}
}
await db.delete(workflow).where(eq(workflow.id, workflowId))
const elapsed = Date.now() - startTime

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
@@ -8,7 +8,7 @@ const logger = createLogger('WorkspaceByIdAPI')
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { knowledgeBase, permissions, workspace } from '@/db/schema'
import { knowledgeBase, permissions, templates, workspace } from '@/db/schema'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
@@ -19,6 +19,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const workspaceId = id
const url = new URL(request.url)
const checkTemplates = url.searchParams.get('check-templates') === 'true'
// Check if user has any access to this workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
@@ -26,6 +28,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
}
// If checking for published templates before deletion
if (checkTemplates) {
try {
// Get all workflows in this workspace
const workspaceWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
if (workspaceWorkflows.length === 0) {
return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] })
}
const workflowIds = workspaceWorkflows.map((w) => w.id)
// Check for published templates that reference these workflows
const publishedTemplates = await db
.select({
id: templates.id,
name: templates.name,
workflowId: templates.workflowId,
})
.from(templates)
.where(inArray(templates.workflowId, workflowIds))
return NextResponse.json({
hasPublishedTemplates: publishedTemplates.length > 0,
publishedTemplates,
count: publishedTemplates.length,
})
} catch (error) {
logger.error(`Error checking published templates for workspace ${workspaceId}:`, error)
return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 })
}
}
// Get workspace details
const workspaceDetails = await db
.select()
@@ -108,6 +146,8 @@ export async function DELETE(
}
const workspaceId = id
const body = await request.json().catch(() => ({}))
const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates
// Check if user has admin permissions to delete workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
@@ -116,10 +156,39 @@ export async function DELETE(
}
try {
logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)
logger.info(
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
)
// Delete workspace and all related data in a transaction
await db.transaction(async (tx) => {
// Get all workflows in this workspace before deletion
const workspaceWorkflows = await tx
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
if (workspaceWorkflows.length > 0) {
const workflowIds = workspaceWorkflows.map((w) => w.id)
// Handle templates based on user choice
if (deleteTemplates) {
// Delete published templates that reference these workflows
await tx.delete(templates).where(inArray(templates.workflowId, workflowIds))
logger.info(`Deleted templates for workflows in workspace ${workspaceId}`)
} else {
// Set workflowId to null for templates to create "orphaned" templates
// This allows templates to remain in marketplace but without source workflows
await tx
.update(templates)
.set({ workflowId: null })
.where(inArray(templates.workflowId, workflowIds))
logger.info(
`Updated templates to orphaned status for workflows in workspace ${workspaceId}`
)
}
}
// Delete all workflows in the workspace - database cascade will handle all workflow-related data
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows,
// workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans,

View File

@@ -91,6 +91,7 @@ describe('Workspace Invitations API Route', () => {
env: {
RESEND_API_KEY: 'test-resend-key',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
EMAIL_DOMAIN: 'test.sim.ai',
},
}))

View File

@@ -5,9 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
import { getSession } from '@/lib/auth'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { db } from '@/db'
import {
permissions,
@@ -240,8 +240,7 @@ async function sendInvitationEmail({
})
)
const emailDomain = env.EMAIL_DOMAIN || getEmailDomain()
const fromAddress = `${env.SENDER_NAME || 'Sim'} <noreply@${emailDomain}>`
const fromAddress = getFromEmailAddress()
logger.info(`Attempting to send email from ${fromAddress} to ${to}`)
@@ -251,7 +250,6 @@ async function sendInvitationEmail({
html: emailHtml,
from: fromAddress,
emailType: 'transactional',
useCustomFromFormat: true,
})
if (result.success) {

View File

@@ -0,0 +1,167 @@
/* Force light mode for chat subdomain by overriding dark mode utilities */
/* This file uses CSS variables from globals.css light mode theme */
/* When inside the chat layout, force all light mode CSS variables */
.chat-light-wrapper {
/* Core Colors - from globals.css light mode */
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
/* Card Colors */
--card: 0 0% 99.2%;
--card-foreground: 0 0% 3.9%;
/* Popover Colors */
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
/* Primary Colors */
--primary: 0 0% 11.2%;
--primary-foreground: 0 0% 98%;
/* Secondary Colors */
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 11.2%;
/* Muted Colors */
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 46.9%;
/* Accent Colors */
--accent: 0 0% 92.5%;
--accent-foreground: 0 0% 11.2%;
/* Destructive Colors */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
/* Border & Input Colors */
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
/* Border Radius */
--radius: 0.5rem;
/* Scrollbar Properties */
--scrollbar-track: 0 0% 85%;
--scrollbar-thumb: 0 0% 65%;
--scrollbar-thumb-hover: 0 0% 55%;
--scrollbar-size: 8px;
/* Workflow Properties */
--workflow-background: 0 0% 100%;
--workflow-dots: 0 0% 94.5%;
--card-background: 0 0% 99.2%;
--card-border: 0 0% 89.8%;
--card-text: 0 0% 3.9%;
--card-hover: 0 0% 96.1%;
/* Base Component Properties */
--base-muted-foreground: #737373;
/* Gradient Colors */
--gradient-primary: 263 85% 70%;
--gradient-secondary: 336 95% 65%;
/* Brand Colors */
--brand-primary-hex: #701ffc;
--brand-primary-hover-hex: #802fff;
--brand-secondary-hex: #6518e6;
--brand-accent-hex: #9d54ff;
--brand-accent-hover-hex: #a66fff;
--brand-background-hex: #0c0c0c;
/* UI Surface Colors */
--surface-elevated: #202020;
}
/* Override dark mode utility classes using CSS variables */
.chat-light-wrapper :is(.dark\:bg-black) {
background-color: hsl(var(--secondary));
}
.chat-light-wrapper :is(.dark\:bg-gray-900) {
background-color: hsl(var(--background));
}
.chat-light-wrapper :is(.dark\:bg-gray-800) {
background-color: hsl(var(--secondary));
}
.chat-light-wrapper :is(.dark\:bg-gray-700) {
background-color: hsl(var(--accent));
}
.chat-light-wrapper :is(.dark\:bg-gray-600) {
background-color: hsl(var(--muted));
}
.chat-light-wrapper :is(.dark\:bg-gray-300) {
background-color: hsl(var(--primary));
}
/* Text color overrides using CSS variables */
.chat-light-wrapper :is(.dark\:text-gray-100) {
color: hsl(var(--primary));
}
.chat-light-wrapper :is(.dark\:text-gray-200) {
color: hsl(var(--foreground));
}
.chat-light-wrapper :is(.dark\:text-gray-300) {
color: hsl(var(--muted-foreground));
}
.chat-light-wrapper :is(.dark\:text-gray-400) {
color: hsl(var(--muted-foreground));
}
.chat-light-wrapper :is(.dark\:text-neutral-600) {
color: hsl(var(--muted-foreground));
}
.chat-light-wrapper :is(.dark\:text-blue-400) {
color: var(--brand-accent-hex);
}
/* Border color overrides using CSS variables */
.chat-light-wrapper :is(.dark\:border-gray-700) {
border-color: hsl(var(--border));
}
.chat-light-wrapper :is(.dark\:border-gray-800) {
border-color: hsl(var(--border));
}
.chat-light-wrapper :is(.dark\:border-gray-600) {
border-color: hsl(var(--border));
}
.chat-light-wrapper :is(.dark\:divide-gray-700) > * + * {
border-color: hsl(var(--border));
}
/* Hover state overrides */
.chat-light-wrapper :is(.dark\:hover\:bg-gray-800\/60:hover) {
background-color: hsl(var(--card-hover));
}
/* Code blocks specific overrides using CSS variables */
.chat-light-wrapper pre:is(.dark\:bg-black) {
background-color: hsl(var(--workflow-dots));
}
.chat-light-wrapper code:is(.dark\:bg-gray-700) {
background-color: hsl(var(--accent));
}
.chat-light-wrapper code:is(.dark\:text-gray-200) {
color: hsl(var(--foreground));
}
/* Force color scheme */
.chat-light-wrapper {
color-scheme: light !important;
}

View File

@@ -481,7 +481,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
// Standard text-based chat interface
return (
<div className='fixed inset-0 z-[100] flex flex-col bg-background'>
<div className='fixed inset-0 z-[100] flex flex-col bg-background text-foreground'>
{/* Header component */}
<ChatHeader chatConfig={chatConfig} starCount={starCount} />

View File

@@ -22,53 +22,14 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
return (
<div className='flex items-center justify-between bg-background/95 px-6 py-4 pt-6 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-8 md:pt-4'>
<div className='flex items-center gap-4'>
{customImage ? (
{customImage && (
<img
src={customImage}
alt={`${chatConfig?.title || 'Chat'} logo`}
className='h-12 w-12 rounded-md object-cover'
className='h-8 w-8 rounded-md object-cover'
/>
) : (
// Default Sim Studio logo when no custom image is provided
<div
className='flex h-12 w-12 items-center justify-center rounded-md'
style={{ backgroundColor: primaryColor }}
>
<svg
width='20'
height='20'
viewBox='0 0 50 50'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z'
fill={primaryColor}
stroke='white'
strokeWidth='3.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z'
fill={primaryColor}
stroke='white'
strokeWidth='4'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398'
stroke='white'
strokeWidth='4'
strokeLinecap='round'
strokeLinejoin='round'
/>
<circle cx='25' cy='11' r='2' fill={primaryColor} />
</svg>
</div>
)}
<h2 className='font-medium text-lg'>
<h2 className='font-medium text-foreground text-lg'>
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
</h2>
</div>

View File

@@ -2,10 +2,10 @@
export function ChatLoadingState() {
return (
<div className='flex min-h-screen items-center justify-center bg-gray-50'>
<div className='flex min-h-screen items-center justify-center bg-background text-foreground'>
<div className='animate-pulse text-center'>
<div className='mx-auto mb-4 h-8 w-48 rounded bg-gray-200' />
<div className='mx-auto h-4 w-64 rounded bg-gray-200' />
<div className='mx-auto mb-4 h-8 w-48 rounded bg-muted' />
<div className='mx-auto h-4 w-64 rounded bg-muted' />
</div>
</div>
)

View File

@@ -0,0 +1,19 @@
'use client'
import { ThemeProvider } from 'next-themes'
import './chat-client.css'
export default function ChatLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute='class'
forcedTheme='light'
enableSystem={false}
disableTransitionOnChange
>
<div className='light chat-light-wrapper' style={{ colorScheme: 'light' }}>
{children}
</div>
</ThemeProvider>
)
}

View File

@@ -3,6 +3,7 @@ import { SpeedInsights } from '@vercel/speed-insights/next'
import type { Metadata, Viewport } from 'next'
import { PublicEnvScript } from 'next-runtime-env'
import { BrandedLayout } from '@/components/branded-layout'
import { generateThemeCSS } from '@/lib/branding/inject-theme'
import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/metadata'
import { env } from '@/lib/env'
import { isHosted } from '@/lib/environment'
@@ -10,6 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import '@/app/globals.css'
import { SessionProvider } from '@/lib/session-context'
import { ThemeProvider } from '@/app/theme-provider'
import { ZoomPrevention } from '@/app/zoom-prevention'
@@ -61,6 +63,7 @@ export const metadata: Metadata = generateBrandedMetadata()
export default function RootLayout({ children }: { children: React.ReactNode }) {
const structuredData = generateStructuredData()
const themeCSS = generateThemeCSS()
return (
<html lang='en' suppressHydrationWarning>
@@ -73,6 +76,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}}
/>
{/* Theme CSS Override */}
{themeCSS && (
<style
id='theme-override'
dangerouslySetInnerHTML={{
__html: themeCSS,
}}
/>
)}
{/* Meta tags for better SEO */}
<meta name='color-scheme' content='light dark' />
<meta name='format-detection' content='telephone=no' />
@@ -111,16 +124,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body suppressHydrationWarning>
<ThemeProvider>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
<SessionProvider>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
</SessionProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -11,8 +11,8 @@ export default function manifest(): MetadataRoute.Manifest {
'Build and deploy AI agents using our Figma-like canvas. Build, write evals, and deploy AI agent workflows that automate workflows and streamline your business processes.',
start_url: '/',
display: 'standalone',
background_color: '#701FFC', // Default Sim brand primary color
theme_color: '#701FFC', // Default Sim brand primary color
background_color: brand.theme?.backgroundColor || '#701FFC',
theme_color: brand.theme?.primaryColor || '#701FFC',
icons: [
{
src: '/favicon/android-chrome-192x192.png',

View File

@@ -29,7 +29,7 @@ export type CategoryValue = (typeof categories)[number]['value']
// Template data structure
export interface Template {
id: string
workflowId: string
workflowId: string | null
userId: string
name: string
description: string | null

View File

@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { getEnv, isTruthy } from '@/lib/env'
interface ExampleCommandProps {
command: string
@@ -32,6 +33,7 @@ export function ExampleCommand({
}: ExampleCommandProps) {
const [mode, setMode] = useState<ExampleMode>('sync')
const [exampleType, setExampleType] = useState<ExampleType>('execute')
const isAsyncEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED'))
// Format the curl command to use a placeholder for the API key
const formatCurlCommand = (command: string, apiKey: string) => {
@@ -146,62 +148,67 @@ export function ExampleCommand({
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='sm'
onClick={() => setMode('sync')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'sync'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
>
Sync
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setMode('async')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'async'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
>
Async
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
disabled={mode === 'sync'}
>
<span className='truncate'>{getExampleTitle()}</span>
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('execute')}
>
Async Execution
</DropdownMenuItem>
<DropdownMenuItem className='cursor-pointer' onClick={() => setExampleType('status')}>
Check Job Status
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('rate-limits')}
>
Rate Limits & Usage
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isAsyncEnabled && (
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='sm'
onClick={() => setMode('sync')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'sync'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
>
Sync
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setMode('async')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'async'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
>
Async
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
disabled={mode === 'sync'}
>
<span className='truncate'>{getExampleTitle()}</span>
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('execute')}
>
Async Execution
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('status')}
>
Check Job Status
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('rate-limits')}
>
Rate Limits & Usage
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className='group relative h-[120px] rounded-md border bg-background transition-colors hover:bg-muted/50'>

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Award,
@@ -18,6 +18,7 @@ import {
Database,
DollarSign,
Edit,
Eye,
FileText,
Folder,
Globe,
@@ -48,6 +49,16 @@ import {
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ColorPicker } from '@/components/ui/color-picker'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -68,6 +79,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
@@ -100,7 +112,6 @@ interface TemplateModalProps {
workflowId: string
}
// Enhanced icon selection with category-relevant icons
const icons = [
// Content & Documentation
{ value: 'FileText', label: 'File Text', component: FileText },
@@ -165,6 +176,10 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
const [existingTemplate, setExistingTemplate] = useState<any>(null)
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
@@ -178,6 +193,63 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
},
})
// Watch form state to determine if all required fields are valid
const formValues = form.watch()
const isFormValid =
form.formState.isValid &&
formValues.name?.trim() &&
formValues.description?.trim() &&
formValues.author?.trim() &&
formValues.category
// Check for existing template when modal opens
useEffect(() => {
if (open && workflowId) {
checkExistingTemplate()
}
}, [open, workflowId])
const checkExistingTemplate = async () => {
setIsLoadingTemplate(true)
try {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (response.ok) {
const result = await response.json()
const template = result.data?.[0] || null
setExistingTemplate(template)
// Pre-fill form with existing template data
if (template) {
form.reset({
name: template.name,
description: template.description,
author: template.author,
category: template.category,
icon: template.icon,
color: template.color,
})
} else {
// No existing template found
setExistingTemplate(null)
// Reset form to defaults
form.reset({
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
category: '',
icon: 'FileText',
color: '#3972F6',
})
}
}
} catch (error) {
logger.error('Error checking existing template:', error)
setExistingTemplate(null)
} finally {
setIsLoadingTemplate(false)
}
}
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
logger.error('User not authenticated')
@@ -201,21 +273,36 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
state: templateState,
}
const response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
let response
if (existingTemplate) {
// Update existing template
response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
} else {
// Create new template
response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create template')
throw new Error(
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
)
}
const result = await response.json()
logger.info('Template created successfully:', result)
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
// Reset form and close modal
form.reset()
@@ -241,7 +328,35 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Publish Template</DialogTitle>
<div className='flex items-center gap-3'>
<DialogTitle className='font-medium text-lg'>
{isLoadingTemplate
? 'Loading...'
: existingTemplate
? 'Update Template'
: 'Publish Template'}
</DialogTitle>
{existingTemplate && (
<div className='flex items-center gap-2'>
{existingTemplate.stars > 0 && (
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
{existingTemplate.stars}
</span>
</div>
)}
{existingTemplate.views > 0 && (
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
<Eye className='h-3 w-3 text-blue-500' />
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
{existingTemplate.views}
</span>
</div>
)}
</div>
)}
</div>
<Button
variant='ghost'
size='icon'
@@ -259,65 +374,189 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
onSubmit={form.handleSubmit(onSubmit)}
className='flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto px-6 py-4'>
<div className='space-y-6'>
<div className='flex gap-3'>
<div className='flex-1 overflow-y-auto px-6 py-6'>
{isLoadingTemplate ? (
<div className='space-y-6'>
{/* Icon and Color row */}
<div className='flex gap-3'>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
</div>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Color picker */}
</div>
</div>
{/* Name field */}
<div>
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
{/* Author and Category row */}
<div className='grid grid-cols-2 gap-4'>
<div>
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
<div>
<Skeleton className='mb-2 h-4 w-16' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Select */}
</div>
</div>
{/* Description field */}
<div>
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
<Skeleton className='h-20 w-full' /> {/* Textarea */}
</div>
</div>
) : (
<div className='space-y-6'>
<div className='flex gap-3'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Icon
</FormLabel>
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
<SelectedIconComponent className='h-4 w-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='z-50 w-84 p-0' align='start'>
<div className='p-3'>
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
{icons.map((icon) => {
const IconComponent = icon.component
return (
<button
key={icon.value}
type='button'
onClick={() => {
field.onChange(icon.value)
setIconPopoverOpen(false)
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
field.value === icon.value &&
'bg-primary text-primary-foreground'
)}
>
<IconComponent className='h-4 w-4' />
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Color
</FormLabel>
<FormControl>
<ColorPicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className='h-10 w-20'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='icon'
name='name'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel>Icon</FormLabel>
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
<SelectedIconComponent className='h-4 w-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='z-50 w-84 p-0' align='start'>
<div className='p-3'>
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
{icons.map((icon) => {
const IconComponent = icon.component
return (
<button
key={icon.value}
type='button'
onClick={() => {
field.onChange(icon.value)
setIconPopoverOpen(false)
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
field.value === icon.value &&
'bg-primary text-primary-foreground'
)}
>
<IconComponent className='h-4 w-4' />
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
<FormControl>
<Input placeholder='Enter template name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='author'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Author
</FormLabel>
<FormControl>
<Input placeholder='Enter author name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='category'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Category
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a category' />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='color'
name='description'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel>Color</FormLabel>
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Description
</FormLabel>
<FormControl>
<ColorPicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className='h-10 w-20'
<Textarea
placeholder='Describe what this template does...'
className='resize-none'
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
@@ -325,91 +564,28 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
)}
/>
</div>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder='Enter template name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='author'
render={({ field }) => (
<FormItem>
<FormLabel>Author</FormLabel>
<FormControl>
<Input placeholder='Enter author name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='category'
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a category' />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Describe what this template does...'
className='resize-none'
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-end'>
<div className='flex items-center'>
{existingTemplate && (
<Button
type='button'
variant='destructive'
onClick={() => setShowDeleteDialog(true)}
disabled={isSubmitting || isLoadingTemplate}
className='h-10 rounded-md px-4 py-2'
>
Delete
</Button>
)}
<Button
type='submit'
disabled={isSubmitting}
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
className={cn(
'font-medium',
'ml-auto font-medium',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
@@ -420,16 +596,59 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Publishing...
{existingTemplate ? 'Updating...' : 'Publishing...'}
</>
) : existingTemplate ? (
'Update Template'
) : (
'Publish'
'Publish Template'
)}
</Button>
</div>
</div>
</form>
</Form>
{existingTemplate && (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this template will remove it from the gallery. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={isDeleting}
onClick={async () => {
if (!existingTemplate) return
setIsDeleting(true)
try {
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'DELETE',
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.error || 'Failed to delete template')
}
setShowDeleteDialog(false)
onOpenChange(false)
} catch (err) {
logger.error('Failed to delete template', err)
} finally {
setIsDeleting(false)
}
}}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</DialogContent>
</Dialog>
)

View File

@@ -18,7 +18,6 @@ import {
import { useParams, useRouter } from 'next/navigation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
@@ -113,6 +112,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
const [isAutoLayouting, setIsAutoLayouting] = useState(false)
// Delete workflow state - grouped for better organization
const [deleteState, setDeleteState] = useState({
showDialog: false,
isDeleting: false,
hasPublishedTemplates: false,
publishedTemplates: [] as any[],
showTemplateChoice: false,
})
// Deployed state management
const [deployedState, setDeployedState] = useState<WorkflowState | null>(null)
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
@@ -337,35 +345,170 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
/**
* Handle deleting the current workflow
* Reset delete state
*/
const handleDeleteWorkflow = () => {
const resetDeleteState = useCallback(() => {
setDeleteState({
showDialog: false,
isDeleting: false,
hasPublishedTemplates: false,
publishedTemplates: [],
showTemplateChoice: false,
})
}, [])
/**
* Navigate to next workflow after deletion
*/
const navigateAfterDeletion = useCallback(
(currentWorkflowId: string) => {
const sidebarWorkflows = getSidebarOrderedWorkflows()
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
// Find next workflow: try next, then previous
let nextWorkflowId: string | null = null
if (sidebarWorkflows.length > 1) {
if (currentIndex < sidebarWorkflows.length - 1) {
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
} else if (currentIndex > 0) {
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
}
}
// Navigate to next workflow or workspace home
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
router.push(`/workspace/${workspaceId}`)
}
},
[workspaceId, router]
)
/**
* Check if workflow has published templates
*/
const checkPublishedTemplates = useCallback(async (workflowId: string) => {
const checkResponse = await fetch(`/api/workflows/${workflowId}?check-templates=true`, {
method: 'DELETE',
})
if (!checkResponse.ok) {
throw new Error(`Failed to check templates: ${checkResponse.statusText}`)
}
return await checkResponse.json()
}, [])
/**
* Delete workflow with optional template handling
*/
const deleteWorkflowWithTemplates = useCallback(
async (workflowId: string, templateAction?: 'keep' | 'delete') => {
const endpoint = templateAction
? `/api/workflows/${workflowId}?deleteTemplates=${templateAction}`
: null
if (endpoint) {
// Use custom endpoint for template handling
const response = await fetch(endpoint, { method: 'DELETE' })
if (!response.ok) {
throw new Error(`Failed to delete workflow: ${response.statusText}`)
}
// Manual registry cleanup since we used custom API
useWorkflowRegistry.setState((state) => {
const newWorkflows = { ...state.workflows }
delete newWorkflows[workflowId]
return {
...state,
workflows: newWorkflows,
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
}
})
} else {
// Use registry's built-in deletion (handles database + state)
await useWorkflowRegistry.getState().removeWorkflow(workflowId)
}
},
[]
)
/**
* Handle deleting the current workflow - called after user confirms
*/
const handleDeleteWorkflow = useCallback(async () => {
const currentWorkflowId = params.workflowId as string
if (!currentWorkflowId || !userPermissions.canEdit) return
const sidebarWorkflows = getSidebarOrderedWorkflows()
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
// Find next workflow: try next, then previous
let nextWorkflowId: string | null = null
if (sidebarWorkflows.length > 1) {
if (currentIndex < sidebarWorkflows.length - 1) {
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
} else if (currentIndex > 0) {
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
try {
// Check if workflow has published templates
const checkData = await checkPublishedTemplates(currentWorkflowId)
if (checkData.hasPublishedTemplates) {
setDeleteState((prev) => ({
...prev,
hasPublishedTemplates: true,
publishedTemplates: checkData.publishedTemplates || [],
showTemplateChoice: true,
isDeleting: false, // Stop showing "Deleting..." and show template choice
}))
return
}
}
// Navigate to next workflow or workspace home
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
router.push(`/workspace/${workspaceId}`)
// No templates, proceed with standard deletion
navigateAfterDeletion(currentWorkflowId)
await deleteWorkflowWithTemplates(currentWorkflowId)
resetDeleteState()
} catch (error) {
logger.error('Error deleting workflow:', error)
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
}
}, [
params.workflowId,
userPermissions.canEdit,
checkPublishedTemplates,
navigateAfterDeletion,
deleteWorkflowWithTemplates,
resetDeleteState,
])
// Remove the workflow from the registry using the URL parameter
useWorkflowRegistry.getState().removeWorkflow(currentWorkflowId)
}
/**
* Handle template action selection
*/
const handleTemplateAction = useCallback(
async (action: 'keep' | 'delete') => {
const currentWorkflowId = params.workflowId as string
if (!currentWorkflowId || !userPermissions.canEdit) return
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
try {
logger.info(`Deleting workflow ${currentWorkflowId} with template action: ${action}`)
navigateAfterDeletion(currentWorkflowId)
await deleteWorkflowWithTemplates(currentWorkflowId, action)
logger.info(
`Successfully deleted workflow ${currentWorkflowId} with template action: ${action}`
)
resetDeleteState()
} catch (error) {
logger.error('Error deleting workflow:', error)
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
}
},
[
params.workflowId,
userPermissions.canEdit,
navigateAfterDeletion,
deleteWorkflowWithTemplates,
resetDeleteState,
]
)
// Helper function to open subscription settings
const openSubscriptionSettings = () => {
@@ -422,7 +565,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
return (
<AlertDialog>
<AlertDialog
open={deleteState.showDialog}
onOpenChange={(open) => {
if (open) {
// Reset all state when opening dialog to ensure clean start
setDeleteState({
showDialog: true,
isDeleting: false,
hasPublishedTemplates: false,
publishedTemplates: [],
showTemplateChoice: false,
})
} else {
resetDeleteState()
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
@@ -444,21 +603,71 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this workflow will permanently remove all associated blocks, executions, and
configuration.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
<AlertDialogTitle>
{deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'}
</AlertDialogTitle>
{deleteState.showTemplateChoice ? (
<div className='space-y-3'>
<AlertDialogDescription>
This workflow has {deleteState.publishedTemplates.length} published template
{deleteState.publishedTemplates.length > 1 ? 's' : ''}:
</AlertDialogDescription>
{deleteState.publishedTemplates.length > 0 && (
<ul className='list-disc space-y-1 pl-6'>
{deleteState.publishedTemplates.map((template) => (
<li key={template.id}>{template.name}</li>
))}
</ul>
)}
<AlertDialogDescription>
What would you like to do with the published template
{deleteState.publishedTemplates.length > 1 ? 's' : ''}?
</AlertDialogDescription>
</div>
) : (
<AlertDialogDescription>
Deleting this workflow will permanently remove all associated blocks, executions,
and configuration.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteWorkflow}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
Delete
</AlertDialogAction>
{deleteState.showTemplateChoice ? (
<div className='flex w-full gap-2'>
<Button
variant='outline'
onClick={() => handleTemplateAction('keep')}
disabled={deleteState.isDeleting}
className='h-9 flex-1 rounded-[8px]'
>
Keep templates
</Button>
<Button
onClick={() => handleTemplateAction('delete')}
disabled={deleteState.isDeleting}
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{deleteState.isDeleting ? 'Deleting...' : 'Delete templates'}
</Button>
</div>
) : (
<>
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
<Button
onClick={(e) => {
e.preventDefault()
handleDeleteWorkflow()
}}
disabled={deleteState.isDeleting}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{deleteState.isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -1002,10 +1211,10 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{renderToggleButton()}
{isExpanded && <ExportControls />}
{isExpanded && renderAutoLayoutButton()}
{renderDuplicateButton()}
{renderDeleteButton()}
{!isDebugging && renderDebugModeToggle()}
{isExpanded && renderPublishButton()}
{renderDeleteButton()}
{renderDuplicateButton()}
{!isDebugging && renderDebugModeToggle()}
{renderDeployButton()}
{isDebugging ? renderDebugControlsBar() : renderRunButton()}

View File

@@ -1,4 +1,4 @@
import { Check, Eye, X } from 'lucide-react'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { useCopilotStore } from '@/stores/copilot/store'
@@ -201,6 +201,34 @@ export function DiffControls() {
logger.warn('Failed to clear preview YAML:', error)
})
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
try {
const { toolCallsById, messages } = useCopilotStore.getState()
let id: string | undefined
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]
if (m.role !== 'assistant' || !m.contentBlocks) continue
const blocks = m.contentBlocks as any[]
for (let bi = blocks.length - 1; bi >= 0; bi--) {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (tn === 'build_workflow' || tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
}
}
}
if (!id) {
const candidates = Object.values(toolCallsById).filter(
(t) => t.name === 'build_workflow' || t.name === 'edit_workflow'
)
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('accepted', id)
} catch {}
// Accept changes without blocking the UI; errors will be logged by the store handler
acceptChanges().catch((error) => {
logger.error('Failed to accept changes (background):', error)
@@ -224,6 +252,34 @@ export function DiffControls() {
logger.warn('Failed to clear preview YAML:', error)
})
// Resolve target toolCallId for build/edit and update to terminal rejected state in the copilot store
try {
const { toolCallsById, messages } = useCopilotStore.getState()
let id: string | undefined
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]
if (m.role !== 'assistant' || !m.contentBlocks) continue
const blocks = m.contentBlocks as any[]
for (let bi = blocks.length - 1; bi >= 0; bi--) {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (tn === 'build_workflow' || tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
}
}
}
if (!id) {
const candidates = Object.values(toolCallsById).filter(
(t) => t.name === 'build_workflow' || t.name === 'edit_workflow'
)
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('rejected', id)
} catch {}
// Reject changes optimistically
rejectChanges().catch((error) => {
logger.error('Failed to reject changes (background):', error)
@@ -232,58 +288,39 @@ export function DiffControls() {
return (
<div className='-translate-x-1/2 fixed bottom-20 left-1/2 z-30'>
<div className='rounded-lg border bg-background/95 p-4 shadow-lg backdrop-blur-sm'>
<div className='flex items-center gap-4'>
{/* Info section */}
<div className='flex items-center gap-2'>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900'>
<Eye className='h-4 w-4 text-purple-600 dark:text-purple-400' />
</div>
<div className='flex flex-col'>
<span className='font-medium text-sm'>
{isShowingDiff ? 'Viewing Proposed Changes' : 'Copilot has proposed changes'}
</span>
{diffMetadata && (
<span className='text-muted-foreground text-xs'>
Source: {diffMetadata.source} {' '}
{new Date(diffMetadata.timestamp).toLocaleTimeString()}
</span>
)}
</div>
</div>
<div className='flex items-center gap-2'>
{/* Toggle (left, icon-only, no background) */}
<Button
variant='ghost'
size='sm'
onClick={handleToggleDiff}
className='h-8 rounded-full px-2 text-muted-foreground hover:bg-transparent'
title={isShowingDiff ? 'View original' : 'Preview changes'}
>
{isShowingDiff ? <Eye className='h-5 w-5' /> : <EyeOff className='h-5 w-5' />}
</Button>
{/* Controls */}
<div className='flex items-center gap-2'>
{/* Toggle View Button */}
<Button
variant={isShowingDiff ? 'default' : 'outline'}
size='sm'
onClick={handleToggleDiff}
className='h-8'
>
{isShowingDiff ? 'View Original' : 'Preview Changes'}
</Button>
{/* Reject (middle, light gray, icon-only) */}
<Button
variant='outline'
size='sm'
onClick={handleReject}
className='h-8 rounded-[6px] border-gray-200 bg-gray-100 px-3 text-gray-700 hover:bg-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'
title='Reject changes'
>
Reject
</Button>
{/* Accept/Reject buttons - only show when viewing diff */}
{isShowingDiff && (
<>
<Button
variant='default'
size='sm'
onClick={handleAccept}
className='h-8 bg-green-600 px-3 hover:bg-green-700'
>
<Check className='mr-1 h-3 w-3' />
Accept
</Button>
<Button variant='destructive' size='sm' onClick={handleReject} className='h-8 px-3'>
<X className='mr-1 h-3 w-3' />
Reject
</Button>
</>
)}
</div>
</div>
{/* Accept (right, brand purple, icon-only) */}
<Button
variant='default'
size='sm'
onClick={handleAccept}
className='h-8 rounded-[6px] bg-[var(--brand-primary-hover-hex)] px-3 text-white hover:bg-[var(--brand-primary-hover-hex)]/90 hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
title='Accept changes'
>
Accept
</Button>
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState }
import { ArrowDown, ArrowUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Notice } from '@/components/ui/notice'
import { ScrollArea } from '@/components/ui/scroll-area'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -32,12 +33,11 @@ interface ChatFile {
}
interface ChatProps {
panelWidth: number
chatMessage: string
setChatMessage: (message: string) => void
}
export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
export function Chat({ chatMessage, setChatMessage }: ChatProps) {
const { activeWorkflowId } = useWorkflowRegistry()
const {
@@ -63,6 +63,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
// File upload state
const [chatFiles, setChatFiles] = useState<ChatFile[]>([])
const [isUploadingFiles, setIsUploadingFiles] = useState(false)
const [uploadErrors, setUploadErrors] = useState<string[]>([])
const [dragCounter, setDragCounter] = useState(0)
const isDragOver = dragCounter > 0
// Scroll state
@@ -280,11 +281,15 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
type: chatFile.type,
file: chatFile.file, // Pass the actual File object
}))
workflowInput.onUploadError = (message: string) => {
setUploadErrors((prev) => [...prev, message])
}
}
// Clear input and files, refocus immediately
setChatMessage('')
setChatFiles([])
setUploadErrors([])
focusInput(10)
// Execute the workflow to generate a response
@@ -560,14 +565,16 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
No messages yet
</div>
) : (
<ScrollArea ref={scrollAreaRef} className='h-full pb-2' hideScrollbar={true}>
<div>
{workflowMessages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<div ref={scrollAreaRef} className='h-full'>
<ScrollArea className='h-full pb-2' hideScrollbar={true}>
<div>
{workflowMessages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
</div>
)}
{/* Scroll to bottom button */}
@@ -615,26 +622,68 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) {
const droppedFiles = Array.from(e.dataTransfer.files)
if (droppedFiles.length > 0) {
const newFiles = droppedFiles.slice(0, 5 - chatFiles.length).map((file) => ({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
}))
setChatFiles([...chatFiles, ...newFiles])
const remainingSlots = Math.max(0, 5 - chatFiles.length)
const candidateFiles = droppedFiles.slice(0, remainingSlots)
const errors: string[] = []
const validNewFiles: ChatFile[] = []
for (const file of candidateFiles) {
if (file.size > 10 * 1024 * 1024) {
errors.push(`${file.name} is too large (max 10MB)`)
continue
}
const isDuplicate = chatFiles.some(
(existingFile) =>
existingFile.name === file.name && existingFile.size === file.size
)
if (isDuplicate) {
errors.push(`${file.name} already added`)
continue
}
validNewFiles.push({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
})
}
if (errors.length > 0) {
setUploadErrors(errors)
}
if (validNewFiles.length > 0) {
setChatFiles([...chatFiles, ...validNewFiles])
}
}
}
}}
>
{/* File upload section */}
<div className='mb-2'>
{uploadErrors.length > 0 && (
<div className='mb-2'>
<Notice variant='error' title='File upload error'>
<ul className='list-disc pl-5'>
{uploadErrors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</Notice>
</div>
)}
<ChatFileUpload
files={chatFiles}
onFilesChange={setChatFiles}
onFilesChange={(files) => {
setChatFiles(files)
}}
maxFiles={5}
maxSize={10}
disabled={!activeWorkflowId || isExecuting || isUploadingFiles}
onError={(errors) => setUploadErrors(errors)}
/>
</div>

View File

@@ -21,6 +21,7 @@ interface ChatFileUploadProps {
maxSize?: number // in MB
acceptedTypes?: string[]
disabled?: boolean
onError?: (errors: string[]) => void
}
export function ChatFileUpload({
@@ -30,6 +31,7 @@ export function ChatFileUpload({
maxSize = 10,
acceptedTypes = ['*'],
disabled = false,
onError,
}: ChatFileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -91,7 +93,7 @@ export function ChatFileUpload({
if (errors.length > 0) {
logger.warn('File upload errors:', errors)
// You could show these errors in a toast or alert
onError?.(errors)
}
if (newFiles.length > 0) {
@@ -168,7 +170,12 @@ export function ChatFileUpload({
ref={fileInputRef}
type='file'
multiple
onChange={(e) => handleFileSelect(e.target.files)}
onChange={(e) => {
handleFileSelect(e.target.files)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}}
className='hidden'
accept={acceptedTypes.join(',')}
disabled={disabled}

View File

@@ -25,7 +25,7 @@ export function Console({ panelWidth }: ConsoleProps) {
No console entries
</div>
) : (
<ScrollArea className='h-full' hideScrollbar={true}>
<ScrollArea className='h-full' hideScrollbar={false}>
<div className='space-y-3'>
{filteredEntries.map((entry) => (
<ConsoleEntry key={entry.id} entry={entry} consoleWidth={panelWidth} />

View File

@@ -78,6 +78,14 @@ if (typeof document !== 'undefined') {
overflow-wrap: anywhere !important;
word-break: break-word !important;
}
/* Reduce top margin for first heading (e.g., right after thinking block) */
.copilot-markdown-wrapper > h1:first-child,
.copilot-markdown-wrapper > h2:first-child,
.copilot-markdown-wrapper > h3:first-child,
.copilot-markdown-wrapper > h4:first-child {
margin-top: 0.25rem !important;
}
`
document.head.appendChild(style)
}
@@ -140,17 +148,17 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
// Headings
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-geist-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
<h1 className='mt-3 mb-3 font-geist-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-8 mb-4 font-geist-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
<h2 className='mt-2.5 mb-2.5 font-geist-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-7 mb-3 font-geist-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
<h3 className='mt-2 mb-2 font-geist-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
{children}
</h3>
),

View File

@@ -19,6 +19,8 @@ export function ThinkingBlock({
}: ThinkingBlockProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(persistedDuration ?? 0)
// Track if the user explicitly collapsed while streaming; sticky per block instance
const userCollapsedRef = useRef<boolean>(false)
// Keep a stable reference to start time that updates when prop changes
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
useEffect(() => {
@@ -28,13 +30,14 @@ export function ThinkingBlock({
}, [persistedStartTime])
useEffect(() => {
// Auto-collapse when streaming ends
// Auto-collapse when streaming ends and reset userCollapsed flag
if (!isStreaming) {
setIsExpanded(false)
userCollapsedRef.current = false
return
}
// Expand once there is visible content while streaming
if (content && content.trim().length > 0) {
// Expand once there is visible content while streaming, unless user collapsed
if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content])
@@ -65,9 +68,16 @@ export function ThinkingBlock({
}
return (
<div className='my-1'>
<div className='mt-1 mb-0'>
<button
onClick={() => setIsExpanded((v) => !v)}
onClick={() => {
setIsExpanded((v) => {
const next = !v
// If user collapses during streaming, remember to not auto-expand again
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
}}
className={cn(
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
'font-normal italic'

View File

@@ -12,7 +12,7 @@ import {
ThumbsUp,
X,
} from 'lucide-react'
import { InlineToolCall } from '@/lib/copilot/tools/inline-tool-call'
import { InlineToolCall } from '@/lib/copilot/inline-tool-call'
import { createLogger } from '@/lib/logs/console/logger'
import { usePreviewStore } from '@/stores/copilot/preview-store'
import { useCopilotStore } from '@/stores/copilot/store'
@@ -594,18 +594,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
if (block.type === 'tool_call') {
// Skip hidden tools (like checkoff_todo)
if (block.toolCall.hidden) {
return null
}
// Visibility and filtering handled by InlineToolCall
return (
<div
key={`tool-${block.toolCall.id}`}
className='transition-opacity duration-300 ease-in-out'
style={{ opacity: 1 }}
>
<InlineToolCall toolCall={block.toolCall} />
<InlineToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
</div>
)
}
@@ -625,7 +621,47 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
</div>
)}
<div className='flex justify-end'>
<div className='flex items-center justify-end gap-0'>
{hasCheckpoints && (
<div className='mr-1 inline-flex items-center justify-center'>
{showRestoreConfirmation ? (
<div className='inline-flex items-center gap-1'>
<button
onClick={handleConfirmRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Confirm restore'
aria-label='Confirm restore'
>
{isRevertingCheckpoint ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Check className='h-3 w-3' />
)}
</button>
<button
onClick={handleCancelRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Cancel restore'
aria-label='Cancel restore'
>
<X className='h-3 w-3' />
</button>
</div>
) : (
<button
onClick={handleRevertToCheckpoint}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Restore workflow to this checkpoint state'
aria-label='Restore'
>
<RotateCcw className='h-3 w-3' />
</button>
)}
</div>
)}
<div className='min-w-0 max-w-[80%]'>
{/* Message content in purple box */}
<div
@@ -639,55 +675,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
<WordWrap text={message.content} />
</div>
</div>
{/* Checkpoints below message */}
{hasCheckpoints && (
<div className='mt-1 flex justify-end'>
<div className='inline-flex items-center gap-0.5 text-muted-foreground text-xs'>
<span className='select-none'>
Restore{showRestoreConfirmation && <span className='ml-0.5'>?</span>}
</span>
<div className='inline-flex w-8 items-center justify-center'>
{showRestoreConfirmation ? (
<div className='inline-flex items-center gap-1'>
<button
onClick={handleConfirmRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Confirm restore'
aria-label='Confirm restore'
>
{isRevertingCheckpoint ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Check className='h-3 w-3' />
)}
</button>
<button
onClick={handleCancelRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Cancel restore'
aria-label='Cancel restore'
>
<X className='h-3 w-3' />
</button>
</div>
) : (
<button
onClick={handleRevertToCheckpoint}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Restore workflow to this checkpoint state'
aria-label='Restore'
>
<RotateCcw className='h-3 w-3' />
</button>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -98,22 +98,22 @@ export const TodoList = memo(function TodoList({
index !== todos.length - 1 && 'border-gray-50 border-b dark:border-gray-800'
)}
>
<div
className={cn(
'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border transition-all',
todo.executing
? 'border-blue-400 dark:border-blue-500'
: todo.completed
{todo.executing ? (
<div className='mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center'>
<Loader2 className='h-3 w-3 animate-spin text-blue-500' />
</div>
) : (
<div
className={cn(
'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border transition-all',
todo.completed
? 'border-blue-500 bg-blue-500'
: 'border-gray-300 dark:border-gray-600'
)}
>
{todo.executing ? (
<Loader2 className='h-3 w-3 animate-spin text-blue-500' />
) : todo.completed ? (
<Check className='h-3 w-3 text-white' strokeWidth={3} />
) : null}
</div>
)}
>
{todo.completed ? <Check className='h-3 w-3 text-white' strokeWidth={3} /> : null}
</div>
)}
<span
className={cn(

View File

@@ -16,10 +16,10 @@ export const CopilotSlider = React.forwardRef<
)}
{...props}
>
<SliderPrimitive.Track className='relative h-2 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
<SliderPrimitive.Track className='relative h-1.5 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
<SliderPrimitive.Range className='absolute h-full bg-primary' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className='block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' />
<SliderPrimitive.Thumb className='block h-3.5 w-3.5 cursor-pointer rounded-full border border-primary/60 bg-background shadow-sm transition-all hover:shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50' />
</SliderPrimitive.Root>
))
CopilotSlider.displayName = 'CopilotSlider'

View File

@@ -120,12 +120,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const setMessage =
controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
// Auto-resize textarea
// Auto-resize textarea and toggle vertical scroll when exceeding max height
useEffect(() => {
const textarea = textareaRef.current
if (textarea) {
const maxHeight = 120
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` // Max height of 120px
const nextHeight = Math.min(textarea.scrollHeight, maxHeight)
textarea.style.height = `${nextHeight}px`
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
}
}, [message])
@@ -431,6 +434,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// Depth toggle state comes from global store; access via useCopilotStore
const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore()
// Ensure MAX mode is off for Fast and Balanced depths
useEffect(() => {
if (agentDepth < 2 && !agentPrefetch) {
setAgentPrefetch(true)
}
}, [agentDepth, agentPrefetch, setAgentPrefetch])
const cycleDepth = () => {
// 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping.
const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3)
@@ -446,24 +456,27 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => {
return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Expert'
return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Behemoth'
}
// Removed descriptive suffixes; concise labels only
const getDepthDescription = (value: 0 | 1 | 2 | 3) => {
if (value === 0)
return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks.'
if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks.'
return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks'
if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks'
if (value === 2)
return 'More reasoning for larger workflows and complex edits, still balanced for speed.'
return 'Maximum reasoning power. Best for complex workflow building and debugging.'
return 'More reasoning for larger workflows and complex edits, still balanced for speed'
return 'Maximum reasoning power. Best for complex workflow building and debugging'
}
const getDepthIconFor = (value: 0 | 1 | 2 | 3) => {
if (value === 0) return <Zap className='h-3 w-3 text-muted-foreground' />
if (value === 1) return <InfinityIcon className='h-3 w-3 text-muted-foreground' />
if (value === 2) return <Brain className='h-3 w-3 text-muted-foreground' />
return <BrainCircuit className='h-3 w-3 text-muted-foreground' />
const colorClass = !agentPrefetch
? 'text-[var(--brand-primary-hover-hex)]'
: 'text-muted-foreground'
if (value === 0) return <Zap className={`h-3 w-3 ${colorClass}`} />
if (value === 1) return <InfinityIcon className={`h-3 w-3 ${colorClass}`} />
if (value === 2) return <Brain className={`h-3 w-3 ${colorClass}`} />
return <BrainCircuit className={`h-3 w-3 ${colorClass}`} />
}
const getDepthIcon = () => getDepthIconFor(agentDepth)
@@ -550,7 +563,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
placeholder={isDragging ? 'Drop files here...' : placeholder}
disabled={disabled}
rows={1}
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
className='mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
style={{ height: 'auto' }}
/>
@@ -636,7 +649,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<Button
variant='ghost'
size='sm'
className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs'
className={cn(
'flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs',
!agentPrefetch
? 'border-[var(--brand-primary-hover-hex)] text-[var(--brand-primary-hover-hex)] hover:bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_8%,transparent)] hover:text-[var(--brand-primary-hover-hex)]'
: 'border-border text-foreground'
)}
title='Choose mode'
>
{getDepthIcon()}
@@ -666,12 +684,25 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Significantly increases depth of reasoning
<br />
<span className='text-[10px] text-muted-foreground italic'>
Only available in Advanced and Behemoth modes
</span>
</TooltipContent>
</Tooltip>
</div>
<Switch
checked={!agentPrefetch}
onCheckedChange={(checked) => setAgentPrefetch(!checked)}
disabled={agentDepth < 2}
title={
agentDepth < 2
? 'MAX mode is only available for Advanced or Expert'
: undefined
}
onCheckedChange={(checked) => {
if (agentDepth < 2) return
setAgentPrefetch(!checked)
}}
/>
</div>
<div className='my-2 flex justify-center'>
@@ -680,9 +711,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<div className='mb-3'>
<div className='mb-2 flex items-center justify-between'>
<span className='font-medium text-xs'>Mode</span>
<span className='text-muted-foreground text-xs'>
{getDepthLabelFor(agentDepth)}
</span>
<div className='flex items-center gap-1'>
{getDepthIconFor(agentDepth)}
<span className='text-muted-foreground text-xs'>
{getDepthLabelFor(agentDepth)}
</span>
</div>
</div>
<div className='relative'>
<Slider

View File

@@ -302,12 +302,12 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
const previewToolCall = lastMessage.toolCalls.find(
(tc) =>
tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW &&
tc.state === 'completed' &&
tc.state === 'success' &&
!isToolCallSeen(tc.id)
)
if (previewToolCall?.result) {
logger.info('Preview workflow completed via native SSE - handling result')
if (previewToolCall) {
logger.info('Preview workflow completed via native SSE')
// Mark as seen to prevent duplicate processing
markToolCallAsSeen(previewToolCall.id)
// Tool call handling logic would go here if needed

View File

@@ -1,7 +1,16 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, ChevronDown, Copy, MoreVertical, Plus, Trash } from 'lucide-react'
import {
AlertTriangle,
ChevronDown,
Copy,
Maximize2,
Minimize2,
MoreVertical,
Plus,
Trash,
} from 'lucide-react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
@@ -52,6 +61,16 @@ export function Variables() {
// Track which variables are currently being edited
const [_activeEditors, setActiveEditors] = useState<Record<string, boolean>>({})
// Collapsed state per variable
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>({})
const toggleCollapsed = (variableId: string) => {
setCollapsedById((prev) => ({
...prev,
[variableId]: !prev[variableId],
}))
}
// Handle variable name change with validation
const handleVariableNameChange = (variableId: string, newName: string) => {
const validatedName = validateName(newName)
@@ -220,7 +239,7 @@ export function Variables() {
</Button>
</div>
) : (
<ScrollArea className='h-full' hideScrollbar={true}>
<ScrollArea className='h-full' hideScrollbar={false}>
<div className='space-y-4'>
{workflowVariables.map((variable) => (
<div key={variable.id} className='space-y-2'>
@@ -298,6 +317,17 @@ export function Variables() {
align='end'
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<DropdownMenuItem
onClick={() => toggleCollapsed(variable.id)}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
{(collapsedById[variable.id] ?? false) ? (
<Maximize2 className='mr-2 h-4 w-4 text-muted-foreground' />
) : (
<Minimize2 className='mr-2 h-4 w-4 text-muted-foreground' />
)}
{(collapsedById[variable.id] ?? false) ? 'Expand' : 'Collapse'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => collaborativeDuplicateVariable(variable.id)}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
@@ -317,71 +347,75 @@ export function Variables() {
</div>
{/* Value area */}
<div className='relative rounded-lg bg-secondary/50'>
{/* Validation indicator */}
{variable.value !== '' && getValidationStatus(variable) && (
<div className='absolute top-2 right-2 z-10'>
<Tooltip>
<TooltipTrigger asChild>
<div className='cursor-help'>
<AlertTriangle className='h-3 w-3 text-muted-foreground' />
</div>
</TooltipTrigger>
<TooltipContent side='bottom' className='max-w-xs'>
<p>{getValidationStatus(variable)}</p>
</TooltipContent>
</Tooltip>
</div>
)}
{!(collapsedById[variable.id] ?? false) && (
<div className='relative rounded-lg bg-secondary/50'>
{/* Validation indicator */}
{variable.value !== '' && getValidationStatus(variable) && (
<div className='absolute top-2 right-2 z-10'>
<Tooltip>
<TooltipTrigger asChild>
<div className='cursor-help'>
<AlertTriangle className='h-3 w-3 text-muted-foreground' />
</div>
</TooltipTrigger>
<TooltipContent side='bottom' className='max-w-xs'>
<p>{getValidationStatus(variable)}</p>
</TooltipContent>
</Tooltip>
</div>
)}
{/* Editor */}
<div className='relative overflow-hidden'>
<div
className='relative min-h-[36px] w-full max-w-full px-3 py-2 font-normal text-sm'
ref={(el) => {
editorRefs.current[variable.id] = el
}}
style={{ maxWidth: '100%' }}
>
{variable.value === '' && (
<div className='pointer-events-none absolute inset-0 flex select-none items-start justify-start px-3 py-2 font-[380] text-muted-foreground text-sm leading-normal'>
<div style={{ lineHeight: '20px' }}>{getPlaceholder(variable.type)}</div>
</div>
)}
<Editor
key={`editor-${variable.id}-${variable.type}`}
value={formatValue(variable)}
onValueChange={handleEditorChange.bind(null, variable)}
onBlur={() => handleEditorBlur(variable.id)}
onFocus={() => handleEditorFocus(variable.id)}
highlight={(code) =>
// Only apply syntax highlighting for non-basic text types
variable.type === 'plain' || variable.type === 'string'
? code
: highlight(
code,
languages[getEditorLanguage(variable.type)],
getEditorLanguage(variable.type)
)
}
padding={0}
style={{
fontFamily: 'inherit',
lineHeight: '20px',
width: '100%',
maxWidth: '100%',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'break-word',
minHeight: '20px',
overflow: 'hidden',
{/* Editor */}
<div className='relative overflow-hidden'>
<div
className='relative min-h-[36px] w-full max-w-full px-3 py-2 font-normal text-sm'
ref={(el) => {
editorRefs.current[variable.id] = el
}}
className='[&>pre]:!max-w-full [&>pre]:!overflow-hidden [&>pre]:!whitespace-pre-wrap [&>pre]:!break-all [&>pre]:!overflow-wrap-break-word [&>textarea]:!max-w-full [&>textarea]:!overflow-hidden [&>textarea]:!whitespace-pre-wrap [&>textarea]:!break-all [&>textarea]:!overflow-wrap-break-word font-[380] text-foreground text-sm leading-normal focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full max-w-full whitespace-pre-wrap break-all overflow-wrap-break-word overflow-hidden font-[380] text-foreground'
/>
style={{ maxWidth: '100%' }}
>
{variable.value === '' && (
<div className='pointer-events-none absolute inset-0 flex select-none items-start justify-start px-3 py-2 font-[380] text-muted-foreground text-sm leading-normal'>
<div style={{ lineHeight: '20px' }}>
{getPlaceholder(variable.type)}
</div>
</div>
)}
<Editor
key={`editor-${variable.id}-${variable.type}`}
value={formatValue(variable)}
onValueChange={handleEditorChange.bind(null, variable)}
onBlur={() => handleEditorBlur(variable.id)}
onFocus={() => handleEditorFocus(variable.id)}
highlight={(code) =>
// Only apply syntax highlighting for non-basic text types
variable.type === 'plain' || variable.type === 'string'
? code
: highlight(
code,
languages[getEditorLanguage(variable.type)],
getEditorLanguage(variable.type)
)
}
padding={0}
style={{
fontFamily: 'inherit',
lineHeight: '20px',
width: '100%',
maxWidth: '100%',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'break-word',
minHeight: '20px',
overflow: 'hidden',
}}
className='[&>pre]:!max-w-full [&>pre]:!overflow-hidden [&>pre]:!whitespace-pre-wrap [&>pre]:!break-all [&>pre]:!overflow-wrap-break-word [&>textarea]:!max-w-full [&>textarea]:!overflow-hidden [&>textarea]:!whitespace-pre-wrap [&>textarea]:!break-all [&>textarea]:!overflow-wrap-break-word font-[380] text-foreground text-sm leading-normal focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full max-w-full whitespace-pre-wrap break-all overflow-wrap-break-word overflow-hidden font-[380] text-foreground'
/>
</div>
</div>
</div>
</div>
)}
</div>
))}

View File

@@ -492,11 +492,7 @@ export function Panel() {
<div className='flex-1 overflow-hidden px-3'>
{/* Keep all tabs mounted but hidden to preserve state and animations */}
<div style={{ display: activeTab === 'chat' ? 'block' : 'none', height: '100%' }}>
<Chat
panelWidth={panelWidth}
chatMessage={chatMessage}
setChatMessage={setChatMessage}
/>
<Chat chatMessage={chatMessage} setChatMessage={setChatMessage} />
</div>
<div style={{ display: activeTab === 'console' ? 'block' : 'none', height: '100%' }}>
<Console panelWidth={panelWidth} />

View File

@@ -1,73 +0,0 @@
'use client'
import * as React from 'react'
import { format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
interface DateInputProps {
blockId: string
subBlockId: string
placeholder?: string
isPreview?: boolean
previewValue?: string | null
disabled?: boolean
}
export function DateInput({
blockId,
subBlockId,
placeholder,
isPreview = false,
previewValue,
disabled = false,
}: DateInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const date = value ? new Date(value) : undefined
const isPastDate = React.useMemo(() => {
if (!date) return false
const today = new Date()
today.setHours(0, 0, 0, 0)
return date < today
}, [date])
const handleDateSelect = (selectedDate: Date | undefined) => {
if (isPreview || disabled) return
if (selectedDate) {
const today = new Date()
today.setHours(0, 0, 0, 0)
}
setStoreValue(selectedDate?.toISOString() || '')
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isPreview || disabled}
className={cn(
'w-full justify-start text-left font-normal',
!date && 'text-muted-foreground',
isPastDate && 'border-red-500'
)}
>
<CalendarIcon className='mr-1 h-4 w-4' />
{date ? format(date, 'MMM d, yy') : <span>{placeholder || 'Pick a date'}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar mode='single' selected={date} onSelect={handleDateSelect} initialFocus />
</PopoverContent>
</Popover>
)
}

View File

@@ -88,6 +88,8 @@ export function MicrosoftFileSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
// Track the last (credentialId, fileId) we attempted to resolve to avoid tight retry loops
const lastMetaAttemptRef = useRef<string>('')
// Handle Microsoft Planner task selection
const [plannerTasks, setPlannerTasks] = useState<PlannerTask[]>([])
@@ -496,11 +498,15 @@ export function MicrosoftFileSelector({
setSelectedFileId('')
onChange('')
}
// Reset memo when credential is cleared
lastMetaAttemptRef.current = ''
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
// Reset memo when switching credentials
lastMetaAttemptRef.current = ''
}
}, [selectedCredentialId, selectedFile, onChange])
@@ -514,10 +520,17 @@ export function MicrosoftFileSelector({
(!selectedFile || selectedFile.id !== value) &&
!isLoadingSelectedFile
) {
// Avoid tight retry loops by memoizing the last attempt tuple
const attemptKey = `${selectedCredentialId}::${value}`
if (lastMetaAttemptRef.current === attemptKey) {
return
}
lastMetaAttemptRef.current = attemptKey
if (serviceId === 'microsoft-planner') {
void fetchPlannerTaskById(value)
} else {
fetchFileById(value)
void fetchFileById(value)
}
}
}, [

View File

@@ -3,6 +3,7 @@
import { useParams } from 'next/navigation'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getEnv } from '@/lib/env'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
ConfluenceFileSelector,
DiscordChannelSelector,
@@ -73,8 +74,12 @@ export function FileSelectorInput({
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
// Determine if the persisted credential belongs to the current viewer
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
const foreignCheckProvider = subBlock.serviceId
? getProviderIdFromServiceId(subBlock.serviceId)
: (subBlock.provider as string) || ''
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || '',
foreignCheckProvider,
(connectedCredential as string) || ''
)
@@ -224,12 +229,6 @@ export function FileSelectorInput({
}
onChange={(issueKey) => {
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
// Clear related fields when a new issue is selected
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}}
domain={domain}
provider='jira'
@@ -353,7 +352,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select SharePoint site'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
@@ -389,7 +388,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId='microsoft-planner'
label={subBlock.placeholder || 'Select task'}
disabled={disabled || !credential || !planId}
disabled={finalDisabled}
showPreview={true}
planId={planId}
workflowId={activeWorkflowId || ''}
@@ -447,7 +446,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Teams message location'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
credential={credential}
selectionType={selectionType}
@@ -490,7 +489,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || `Select ${itemType}`}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
credentialId={credential}
itemType={itemType}
@@ -531,7 +530,7 @@ export function FileSelectorInput({
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={disabled || !credential}
disabled={finalDisabled}
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}

View File

@@ -4,7 +4,6 @@ export { Code } from './code'
export { ComboBox } from './combobox'
export { ConditionInput } from './condition-input'
export { CredentialSelector } from './credential-selector/credential-selector'
export { DateInput } from './date-input'
export { DocumentSelector } from './document-selector/document-selector'
export { Dropdown } from './dropdown'
export { EvalInput } from './eval-input'

View File

@@ -18,6 +18,7 @@ interface CodeEditorProps {
highlightVariables?: boolean
onKeyDown?: (e: React.KeyboardEvent) => void
disabled?: boolean
schemaParameters?: Array<{ name: string; type: string; description: string; required: boolean }>
}
export function CodeEditor({
@@ -30,6 +31,7 @@ export function CodeEditor({
highlightVariables = true,
onKeyDown,
disabled = false,
schemaParameters = [],
}: CodeEditorProps) {
const [code, setCode] = useState(value)
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
@@ -120,25 +122,80 @@ export function CodeEditor({
// First, get the default Prism highlighting
let highlighted = highlight(code, languages[language], language)
// Then, highlight environment variables with {{var_name}} syntax in blue
if (highlighted.includes('{{')) {
highlighted = highlighted.replace(
/\{\{([^}]+)\}\}/g,
'<span class="text-blue-500">{{$1}}</span>'
)
// Collect all syntax highlights to apply in a single pass
type SyntaxHighlight = {
start: number
end: number
replacement: string
}
const highlights: SyntaxHighlight[] = []
// Also highlight tags with <tag_name> syntax in blue
if (highlighted.includes('<') && !language.includes('html')) {
highlighted = highlighted.replace(/<([^>\s/]+)>/g, (match, group) => {
// Avoid replacing HTML tags in comments
if (match.startsWith('<!--') || match.includes('</')) {
return match
}
return `<span class="text-blue-500">&lt;${group}&gt;</span>`
// Find environment variables with {{var_name}} syntax
let match
const envVarRegex = /\{\{([^}]+)\}\}/g
while ((match = envVarRegex.exec(highlighted)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
replacement: `<span class="text-blue-500">${match[0]}</span>`,
})
}
// Find tags with <tag_name> syntax (not in HTML context)
if (!language.includes('html')) {
const tagRegex = /<([^>\s/]+)>/g
while ((match = tagRegex.exec(highlighted)) !== null) {
// Skip HTML comments and closing tags
if (!match[0].startsWith('<!--') && !match[0].includes('</')) {
const escaped = `&lt;${match[1]}&gt;`
highlights.push({
start: match.index,
end: match.index + match[0].length,
replacement: `<span class="text-blue-500">${escaped}</span>`,
})
}
}
}
// Find schema parameters as whole words
if (schemaParameters.length > 0) {
schemaParameters.forEach((param) => {
// Escape special regex characters in parameter name
const escapedName = param.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const paramRegex = new RegExp(`\\b(${escapedName})\\b`, 'g')
while ((match = paramRegex.exec(highlighted)) !== null) {
// Check if this position is already inside an HTML tag
// by looking for unclosed < before this position
let insideTag = false
let pos = match.index - 1
while (pos >= 0) {
if (highlighted[pos] === '>') break
if (highlighted[pos] === '<') {
insideTag = true
break
}
pos--
}
if (!insideTag) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
replacement: `<span class="text-green-600 font-medium">${match[0]}</span>`,
})
}
}
})
}
// Sort highlights by start position (reverse order to maintain positions)
highlights.sort((a, b) => b.start - a.start)
// Apply all highlights
highlights.forEach(({ start, end, replacement }) => {
highlighted = highlighted.slice(0, start) + replacement + highlighted.slice(end)
})
return highlighted
}
@@ -204,12 +261,17 @@ export function CodeEditor({
disabled={disabled}
style={{
fontFamily: 'inherit',
minHeight: '46px',
minHeight: minHeight,
lineHeight: '21px',
height: '100%',
}}
className={cn('focus:outline-none', isCollapsed && 'pointer-events-none select-none')}
className={cn(
'h-full focus:outline-none',
isCollapsed && 'pointer-events-none select-none'
)}
textareaClassName={cn(
'focus:outline-none focus:ring-0 bg-transparent',
'!min-h-full !h-full resize-none !block',
(isCollapsed || disabled) && 'pointer-events-none'
)}
/>

View File

@@ -22,6 +22,7 @@ import {
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
import { Label } from '@/components/ui/label'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
@@ -36,6 +37,7 @@ interface CustomToolModalProps {
onOpenChange: (open: boolean) => void
onSave: (tool: CustomTool) => void
onDelete?: (toolId: string) => void
blockId: string
initialValues?: {
id?: string
schema: any
@@ -61,6 +63,7 @@ export function CustomToolModal({
onOpenChange,
onSave,
onDelete,
blockId,
initialValues,
}: CustomToolModalProps) {
const [activeSection, setActiveSection] = useState<ToolSection>('schema')
@@ -237,12 +240,16 @@ try {
// Environment variables and tags dropdown state
const [showEnvVars, setShowEnvVars] = useState(false)
const [showTags, setShowTags] = useState(false)
const [showSchemaParams, setShowSchemaParams] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const codeEditorRef = useRef<HTMLDivElement>(null)
const schemaParamsDropdownRef = useRef<HTMLDivElement>(null)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
// Add state for dropdown positioning
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 })
// Schema params keyboard navigation
const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0)
const addTool = useCustomToolsStore((state) => state.addTool)
const updateTool = useCustomToolsStore((state) => state.updateTool)
@@ -270,6 +277,21 @@ try {
}
}, [open, initialValues])
// Close schema params dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
schemaParamsDropdownRef.current &&
!schemaParamsDropdownRef.current.contains(event.target as Node)
) {
setShowSchemaParams(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const resetForm = () => {
setJsonSchema('')
setFunctionCode('')
@@ -309,6 +331,15 @@ try {
return false
}
// Validate that parameters object exists with correct structure
if (!parsed.function.parameters) {
return false
}
if (!parsed.function.parameters.type || parsed.function.parameters.properties === undefined) {
return false
}
return true
} catch (_error) {
return false
@@ -320,6 +351,25 @@ try {
return true // Allow empty code
}
// Extract parameters from JSON schema for autocomplete
const schemaParameters = useMemo(() => {
try {
if (!jsonSchema) return []
const parsed = JSON.parse(jsonSchema)
const properties = parsed?.function?.parameters?.properties
if (!properties) return []
return Object.keys(properties).map((key) => ({
name: key,
type: properties[key].type || 'any',
description: properties[key].description || '',
required: parsed?.function?.parameters?.required?.includes(key) || false,
}))
} catch {
return []
}
}, [jsonSchema])
// Memoize validation results to prevent unnecessary recalculations
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
const isCodeValid = useMemo(() => validateFunctionCode(functionCode), [functionCode])
@@ -350,6 +400,34 @@ try {
return
}
// Validate parameters structure - must be present
if (!parsed.function.parameters) {
setSchemaError('Missing function.parameters object')
setActiveSection('schema')
return
}
if (!parsed.function.parameters.type) {
setSchemaError('Missing parameters.type field')
setActiveSection('schema')
return
}
if (parsed.function.parameters.properties === undefined) {
setSchemaError('Missing parameters.properties field')
setActiveSection('schema')
return
}
if (
typeof parsed.function.parameters.properties !== 'object' ||
parsed.function.parameters.properties === null
) {
setSchemaError('parameters.properties must be an object')
setActiveSection('schema')
return
}
// Check for duplicate tool name
const toolName = parsed.function.name
const customToolsStore = useCustomToolsStore.getState()
@@ -439,7 +517,52 @@ try {
// Prevent updates during AI generation/streaming
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
setJsonSchema(value)
if (schemaError) {
// Real-time validation - show error immediately when schema is invalid
if (value.trim()) {
try {
const parsed = JSON.parse(value)
if (!parsed.type || parsed.type !== 'function') {
setSchemaError('Missing "type": "function"')
return
}
if (!parsed.function || !parsed.function.name) {
setSchemaError('Missing function.name field')
return
}
if (!parsed.function.parameters) {
setSchemaError('Missing function.parameters object')
return
}
if (!parsed.function.parameters.type) {
setSchemaError('Missing parameters.type field')
return
}
if (parsed.function.parameters.properties === undefined) {
setSchemaError('Missing parameters.properties field')
return
}
if (
typeof parsed.function.parameters.properties !== 'object' ||
parsed.function.parameters.properties === null
) {
setSchemaError('parameters.properties must be an object')
return
}
// Schema is valid, clear any existing error
setSchemaError(null)
} catch {
setSchemaError('Invalid JSON format')
}
} else {
// Clear error when schema is empty (will be caught during save)
setSchemaError(null)
}
}
@@ -499,9 +622,40 @@ try {
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
// Show/hide schema parameters dropdown based on typing context
if (!codeGeneration.isStreaming && schemaParameters.length > 0) {
const schemaParamTrigger = checkSchemaParamTrigger(value, pos, schemaParameters)
if (schemaParamTrigger.show && !showSchemaParams) {
setShowSchemaParams(true)
setSchemaParamSelectedIndex(0)
} else if (!schemaParamTrigger.show && showSchemaParams) {
setShowSchemaParams(false)
}
}
}
}
// Function to check if we should show schema parameters dropdown
const checkSchemaParamTrigger = (text: string, cursorPos: number, parameters: any[]) => {
if (parameters.length === 0) return { show: false, searchTerm: '' }
// Look for partial parameter names after common patterns like 'const ', '= ', etc.
const beforeCursor = text.substring(0, cursorPos)
const words = beforeCursor.split(/[\s=();,{}[\]]+/)
const currentWord = words[words.length - 1] || ''
// Show dropdown if typing and current word could be a parameter
if (currentWord.length > 0 && /^[a-zA-Z_][\w]*$/.test(currentWord)) {
const matchingParams = parameters.filter((param) =>
param.name.toLowerCase().startsWith(currentWord.toLowerCase())
)
return { show: matchingParams.length > 0, searchTerm: currentWord, matches: matchingParams }
}
return { show: false, searchTerm: '' }
}
// Handle environment variable selection
const handleEnvVarSelect = (newValue: string) => {
setFunctionCode(newValue)
@@ -515,6 +669,32 @@ try {
setActiveSourceBlockId(null)
}
// Handle schema parameter selection
const handleSchemaParamSelect = (paramName: string) => {
const textarea = codeEditorRef.current?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
const beforeCursor = functionCode.substring(0, pos)
const afterCursor = functionCode.substring(pos)
// Find the start of the current word
const words = beforeCursor.split(/[\s=();,{}[\]]+/)
const currentWord = words[words.length - 1] || ''
const wordStart = beforeCursor.lastIndexOf(currentWord)
// Replace the current partial word with the selected parameter
const newValue = beforeCursor.substring(0, wordStart) + paramName + afterCursor
setFunctionCode(newValue)
setShowSchemaParams(false)
// Set cursor position after the inserted parameter
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(wordStart + paramName.length, wordStart + paramName.length)
}, 0)
}
}
// Handle key press events
const handleKeyDown = (e: React.KeyboardEvent) => {
// Allow AI prompt interaction (e.g., Escape to close prompt bar)
@@ -535,10 +715,14 @@ try {
e.stopPropagation()
return
}
// Close dropdowns only if AI prompt isn't active
if (!showEnvVars && !showTags) {
// Close dropdowns first, only close modal if no dropdowns are open
if (showEnvVars || showTags || showSchemaParams) {
setShowEnvVars(false)
setShowTags(false)
setShowSchemaParams(false)
e.preventDefault()
e.stopPropagation()
return
}
}
@@ -552,7 +736,37 @@ try {
return
}
// Let dropdowns handle their own keyboard events if visible
// Handle schema parameters dropdown keyboard navigation
if (showSchemaParams && schemaParameters.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1))
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (schemaParamSelectedIndex >= 0 && schemaParamSelectedIndex < schemaParameters.length) {
const selectedParam = schemaParameters[schemaParamSelectedIndex]
handleSchemaParamSelect(selectedParam.name)
}
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setShowSchemaParams(false)
break
}
return // Don't handle other dropdown events when schema params is active
}
// Let other dropdowns handle their own keyboard events if visible
if (showEnvVars || showTags) {
if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
e.preventDefault()
@@ -619,6 +833,16 @@ try {
<DialogContent
className='flex h-[80vh] flex-col gap-0 p-0 sm:max-w-[700px]'
hideCloseButton
onKeyDown={(e) => {
// Intercept Escape key when dropdowns are open
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
e.preventDefault()
e.stopPropagation()
setShowEnvVars(false)
setShowTags(false)
setShowSchemaParams(false)
}
}}
>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
@@ -729,7 +953,7 @@ try {
</div>
{schemaError &&
!schemaGeneration.isStreaming && ( // Hide schema error while streaming
<span className='ml-4 flex-shrink-0 text-red-600 text-sm'>{schemaError}</span>
<div className='ml-4 break-words text-red-600 text-sm'>{schemaError}</div>
)}
</div>
<CodeEditor
@@ -799,9 +1023,25 @@ try {
</div>
{codeError &&
!codeGeneration.isStreaming && ( // Hide code error while streaming
<span className='ml-4 flex-shrink-0 text-red-600 text-sm'>{codeError}</span>
<div className='ml-4 break-words text-red-600 text-sm'>{codeError}</div>
)}
</div>
{schemaParameters.length > 0 && (
<div className='mb-2 rounded-md bg-muted/50 p-2'>
<p className='text-muted-foreground text-xs'>
<span className='font-medium'>Available parameters:</span>{' '}
{schemaParameters.map((param, index) => (
<span key={param.name}>
<code className='rounded bg-background px-1 py-0.5 text-foreground'>
{param.name}
</code>
{index < schemaParameters.length - 1 && ', '}
</span>
))}
{'. '}Start typing a parameter name for autocomplete.
</p>
</div>
)}
<div ref={codeEditorRef} className='relative'>
<CodeEditor
value={functionCode}
@@ -819,6 +1059,7 @@ try {
highlightVariables={true}
disabled={codeGeneration.isLoading || codeGeneration.isStreaming} // Use disabled prop instead of readOnly
onKeyDown={handleKeyDown} // Pass keydown handler
schemaParameters={schemaParameters} // Pass schema parameters for highlighting
/>
{/* Environment variables dropdown */}
@@ -847,7 +1088,7 @@ try {
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId=''
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={functionCode}
cursorPosition={cursorPosition}
@@ -863,6 +1104,49 @@ try {
}}
/>
)}
{/* Schema parameters dropdown */}
{showSchemaParams && schemaParameters.length > 0 && (
<div
ref={schemaParamsDropdownRef}
className='absolute z-[9999] mt-1 w-64 overflow-visible rounded-md border bg-popover shadow-md'
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
}}
>
<div className='py-1'>
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Available Parameters
</div>
<div>
{schemaParameters.map((param, index) => (
<button
key={param.name}
onClick={() => handleSchemaParamSelect(param.name)}
onMouseEnter={() => setSchemaParamSelectedIndex(index)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
index === schemaParamSelectedIndex &&
'bg-accent text-accent-foreground'
)}
>
<div
className='flex h-5 w-5 items-center justify-center rounded'
style={{ backgroundColor: '#2F8BFF' }}
>
<span className='h-3 w-3 font-bold text-white text-xs'>P</span>
</div>
<span className='flex-1 truncate'>{param.name}</span>
<span className='text-muted-foreground text-xs'>{param.type}</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
<div className='h-6' />
</div>
@@ -899,11 +1183,27 @@ try {
Cancel
</Button>
{activeSection === 'schema' ? (
<Button onClick={() => setActiveSection('code')} disabled={!isSchemaValid}>
<Button
onClick={() => setActiveSection('code')}
disabled={!isSchemaValid || !!schemaError}
>
Next
</Button>
) : (
<Button onClick={handleSave}>{isEditing ? 'Update Tool' : 'Save Tool'}</Button>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button onClick={handleSave} disabled={!isSchemaValid}>
{isEditing ? 'Update Tool' : 'Save Tool'}
</Button>
</span>
</TooltipTrigger>
{!isSchemaValid && (
<TooltipContent side='top'>
<p>Invalid JSON schema</p>
</TooltipContent>
)}
</Tooltip>
)}
</div>
</div>

View File

@@ -20,7 +20,6 @@ import {
CheckboxList,
Code,
ComboBox,
DateInput,
FileSelectorInput,
FileUpload,
LongInput,
@@ -172,33 +171,6 @@ function TableSyncWrapper({
)
}
function DateInputSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
disabled: boolean
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<DateInput
blockId={blockId}
subBlockId={paramId}
placeholder={uiComponent.placeholder}
disabled={disabled}
/>
</GenericSyncWrapper>
)
}
function TimeInputSyncWrapper({
blockId,
paramId,
@@ -1159,18 +1131,6 @@ export function ToolInput({
/>
)
case 'date-input':
return (
<DateInputSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
/>
)
case 'time-input':
return (
<TimeInputSyncWrapper
@@ -1360,7 +1320,7 @@ export function ToolInput({
// For custom tools, extract parameters from schema
const customToolParams =
isCustomTool && tool.schema
isCustomTool && tool.schema && tool.schema.function?.parameters?.properties
? Object.entries(tool.schema.function.parameters.properties || {}).map(
([paramId, param]: [string, any]) => ({
id: paramId,
@@ -1864,6 +1824,7 @@ export function ToolInput({
}}
onSave={editingToolIndex !== null ? handleSaveCustomTool : handleAddCustomTool}
onDelete={handleDeleteTool}
blockId={blockId}
initialValues={
editingToolIndex !== null && selectedTools[editingToolIndex]?.type === 'custom-tool'
? {

View File

@@ -65,7 +65,8 @@ export function useSubBlockValue<T = any>(
const storeValue = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return null
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
if (!activeWorkflowId) return undefined
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]

View File

@@ -11,7 +11,6 @@ import {
ComboBox,
ConditionInput,
CredentialSelector,
DateInput,
DocumentSelector,
Dropdown,
EvalInput,
@@ -256,17 +255,6 @@ export function SubBlock({
disabled={isDisabled}
/>
)
case 'date-input':
return (
<DateInput
blockId={blockId}
subBlockId={config.id}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
/>
)
case 'time-input':
return (
<TimeInput

View File

@@ -50,7 +50,9 @@ export function useCurrentWorkflow(): CurrentWorkflow {
// Create the abstracted interface
const currentWorkflow = useMemo((): CurrentWorkflow => {
// Determine which workflow to use - only use diff if it's ready
const shouldUseDiff = isShowingDiff && isDiffReady && !!diffWorkflow
const hasDiffBlocks =
!!diffWorkflow && Object.keys((diffWorkflow as any).blocks || {}).length > 0
const shouldUseDiff = isShowingDiff && isDiffReady && hasDiffBlocks
const activeWorkflow = shouldUseDiff ? diffWorkflow : normalWorkflow
return {

View File

@@ -198,35 +198,37 @@ export function useWand({
const { done, value } = await reader.read()
if (done) break
// Process incoming chunks
const text = decoder.decode(value)
const lines = text.split('\n').filter((line) => line.trim() !== '')
// Process incoming chunks using SSE format (identical to Chat panel)
const chunk = decoder.decode(value)
const lines = chunk.split('\n\n')
for (const line of lines) {
try {
const data = JSON.parse(line)
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6))
// Check if there's an error
if (data.error) {
throw new Error(data.error)
}
// Process chunk
if (data.chunk && !data.done) {
accumulatedContent += data.chunk
// Stream each chunk to the UI immediately
if (onStreamChunk) {
onStreamChunk(data.chunk)
// Check if there's an error
if (data.error) {
throw new Error(data.error)
}
}
// Check if streaming is complete
if (data.done) {
break
// Process chunk
if (data.chunk) {
accumulatedContent += data.chunk
// Stream each chunk to the UI immediately
if (onStreamChunk) {
onStreamChunk(data.chunk)
}
}
// Check if streaming is complete
if (data.done) {
break
}
} catch (parseError) {
// Continue processing other lines
logger.debug('Failed to parse SSE line', { line, parseError })
}
} catch (parseError) {
// Continue processing other lines
logger.debug('Failed to parse streaming line', { line, parseError })
}
}
}

View File

@@ -279,15 +279,17 @@ export function useWorkflowExecution() {
// Handle file uploads if present
const uploadedFiles: any[] = []
console.log('Checking for files to upload:', workflowInput.files)
interface UploadErrorCapableInput {
onUploadError: (message: string) => void
}
const isUploadErrorCapable = (value: unknown): value is UploadErrorCapableInput =>
!!value &&
typeof value === 'object' &&
'onUploadError' in (value as any) &&
typeof (value as any).onUploadError === 'function'
if (workflowInput.files && Array.isArray(workflowInput.files)) {
try {
console.log('Processing files for upload:', workflowInput.files.length)
for (const fileData of workflowInput.files) {
console.log('Uploading file:', fileData.name, fileData.size)
console.log('File data:', fileData)
// Create FormData for upload
const formData = new FormData()
formData.append('file', fileData.file)
@@ -303,8 +305,6 @@ export function useWorkflowExecution() {
if (response.ok) {
const uploadResult = await response.json()
console.log('Upload successful:', uploadResult)
// Convert upload result to clean UserFile format
const processUploadResult = (result: any) => ({
id:
@@ -327,23 +327,28 @@ export function useWorkflowExecution() {
// Single file upload - the result IS the file object
uploadedFiles.push(processUploadResult(uploadResult))
} else {
console.error('Unexpected upload response format:', uploadResult)
logger.error('Unexpected upload response format:', uploadResult)
}
} else {
const errorText = await response.text()
console.error(
`Failed to upload file ${fileData.name}:`,
response.status,
errorText
)
const message = `Failed to upload ${fileData.name}: ${response.status} ${errorText}`
logger.error(message)
if (isUploadErrorCapable(workflowInput)) {
try {
workflowInput.onUploadError(message)
} catch {}
}
}
}
console.log('All files processed. Uploaded files:', uploadedFiles)
// Update workflow input with uploaded files
workflowInput.files = uploadedFiles
} catch (error) {
console.error('Error uploading files:', error)
logger.error('Error uploading files:', error)
if (isUploadErrorCapable(workflowInput)) {
try {
workflowInput.onUploadError('Unexpected error uploading files')
} catch {}
}
// Continue execution even if file upload fails
workflowInput.files = []
}

View File

@@ -65,7 +65,8 @@ export function getWorkflowExecutionContext(): WorkflowExecutionContext {
const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore.getState()
// Determine which workflow to use - same logic as useCurrentWorkflow
const shouldUseDiff = isShowingDiff && isDiffReady && !!diffWorkflow
const hasDiffBlocks = !!diffWorkflow && Object.keys((diffWorkflow as any).blocks || {}).length > 0
const shouldUseDiff = isShowingDiff && isDiffReady && hasDiffBlocks
const currentWorkflow = shouldUseDiff ? diffWorkflow : workflowState
const { getAllVariables } = useEnvironmentStore.getState()

View File

@@ -352,11 +352,13 @@ export const isPointInLoopNode = (
const containingNodes = getNodes()
.filter((n) => isContainerType(n.type))
.filter((n) => {
// Use absolute coordinates for nested containers
const absolutePos = getNodeAbsolutePosition(n.id, getNodes)
const rect = {
left: n.position.x,
right: n.position.x + (n.data?.width || DEFAULT_CONTAINER_WIDTH),
top: n.position.y,
bottom: n.position.y + (n.data?.height || DEFAULT_CONTAINER_HEIGHT),
left: absolutePos.x,
right: absolutePos.x + (n.data?.width || DEFAULT_CONTAINER_WIDTH),
top: absolutePos.y,
bottom: absolutePos.y + (n.data?.height || DEFAULT_CONTAINER_HEIGHT),
}
return (
@@ -368,7 +370,8 @@ export const isPointInLoopNode = (
})
.map((n) => ({
loopId: n.id,
loopPosition: n.position,
// Return absolute position so callers can compute relative placement correctly
loopPosition: getNodeAbsolutePosition(n.id, getNodes),
dimensions: {
width: n.data?.width || DEFAULT_CONTAINER_WIDTH,
height: n.data?.height || DEFAULT_CONTAINER_HEIGHT,

View File

@@ -281,6 +281,41 @@ const WorkflowContent = React.memo(() => {
[getNodes]
)
// Compute the absolute position of a node's source anchor (right-middle)
const getNodeAnchorPosition = useCallback(
(nodeId: string): { x: number; y: number } => {
const node = getNodes().find((n) => n.id === nodeId)
const absPos = getNodeAbsolutePositionWrapper(nodeId)
if (!node) {
return absPos
}
// Use known defaults per node type without type casting
const isSubflow = node.type === 'subflowNode'
const width = isSubflow
? typeof node.data?.width === 'number'
? node.data.width
: 500
: typeof node.width === 'number'
? node.width
: 350
const height = isSubflow
? typeof node.data?.height === 'number'
? node.data.height
: 300
: typeof node.height === 'number'
? node.height
: 100
return {
x: absPos.x + width,
y: absPos.y + height / 2,
}
},
[getNodes, getNodeAbsolutePositionWrapper]
)
// Auto-layout handler - now uses frontend auto layout for immediate updates
const handleAutoLayout = useCallback(async () => {
if (Object.keys(blocks).length === 0) return
@@ -373,22 +408,37 @@ const WorkflowContent = React.memo(() => {
// Handle drops
const findClosestOutput = useCallback(
(newNodePosition: { x: number; y: number }): BlockData | null => {
const existingBlocks = Object.entries(blocks)
.filter(([_, block]) => block.enabled)
.map(([id, block]) => ({
id,
type: block.type,
position: block.position,
distance: Math.sqrt(
(block.position.x - newNodePosition.x) ** 2 +
(block.position.y - newNodePosition.y) ** 2
),
}))
// Determine if drop is inside a container; if not, exclude child nodes from candidates
const containerAtPoint = isPointInLoopNodeWrapper(newNodePosition)
const nodeIndex = new Map(getNodes().map((n) => [n.id, n]))
const candidates = Object.entries(blocks)
.filter(([id, block]) => {
if (!block.enabled) return false
const node = nodeIndex.get(id)
if (!node) return false
// If dropping outside containers, ignore blocks that are inside a container
if (!containerAtPoint && node.parentId) return false
return true
})
.map(([id, block]) => {
const anchor = getNodeAnchorPosition(id)
const distance = Math.sqrt(
(anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2
)
return {
id,
type: block.type,
position: anchor,
distance,
}
})
.sort((a, b) => a.distance - b.distance)
return existingBlocks[0] || null
return candidates[0] || null
},
[blocks]
[blocks, getNodes, getNodeAnchorPosition, isPointInLoopNodeWrapper]
)
// Determine the appropriate source handle based on block type
@@ -667,6 +717,11 @@ const WorkflowContent = React.memo(() => {
y: position.y - containerInfo.loopPosition.y,
}
// Capture existing child blocks before adding the new one
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === containerInfo.loopId
)
// Add block with parent info
addBlock(id, data.type, name, relativePosition, {
parentId: containerInfo.loopId,
@@ -680,12 +735,35 @@ const WorkflowContent = React.memo(() => {
// Auto-connect logic for blocks inside containers
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (isAutoConnectEnabled && data.type !== 'starter') {
// First priority: Connect to the container's start node
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
const containerType = containerNode?.type
if (existingChildBlocks.length > 0) {
// Connect to the nearest existing child block within the container
const closestBlock = existingChildBlocks
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - relativePosition.x) ** 2 +
(b.position.y - relativePosition.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
if (containerType === 'subflowNode') {
// Connect from the container's start node to the new block
if (closestBlock) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
addEdge({
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
} else {
// No existing children: connect from the container's start handle
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
@@ -699,45 +777,6 @@ const WorkflowContent = React.memo(() => {
targetHandle: 'target',
type: 'workflowEdge',
})
} else {
// Fallback: Try to find other nodes in the container to connect to
const containerNodes = getNodes().filter((n) => n.parentId === containerInfo.loopId)
if (containerNodes.length > 0) {
// Connect to the closest node in the container
const closestNode = containerNodes
.map((n) => ({
id: n.id,
distance: Math.sqrt(
(n.position.x - relativePosition.x) ** 2 +
(n.position.y - relativePosition.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]
if (closestNode) {
// Get appropriate source handle
const sourceNode = getNodes().find((n) => n.id === closestNode.id)
const sourceType = sourceNode?.data?.type
// Default source handle
let sourceHandle = 'source'
// For condition blocks, use the condition-true handle
if (sourceType === 'condition') {
sourceHandle = 'condition-true'
}
addEdge({
id: crypto.randomUUID(),
source: closestNode.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
}
}
} else {
@@ -1396,8 +1435,69 @@ const WorkflowContent = React.memo(() => {
// Update the node's parent relationship
if (potentialParentId) {
// Compute relative position BEFORE updating parent to avoid stale state
const containerAbsPosBefore = getNodeAbsolutePositionWrapper(potentialParentId)
const nodeAbsPosBefore = getNodeAbsolutePositionWrapper(node.id)
const relativePositionBefore = {
x: nodeAbsPosBefore.x - containerAbsPosBefore.x,
y: nodeAbsPosBefore.y - containerAbsPosBefore.y,
}
// Moving to a new parent container
updateNodeParent(node.id, potentialParentId)
// Auto-connect when moving an existing block into a container
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (isAutoConnectEnabled) {
// Existing children in the target container (excluding the moved node)
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === potentialParentId && b.id !== node.id
)
if (existingChildBlocks.length > 0) {
// Connect from nearest existing child inside the container
const closestBlock = existingChildBlocks
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - relativePositionBefore.x) ** 2 +
(b.position.y - relativePositionBefore.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
if (closestBlock) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
addEdge({
id: crypto.randomUUID(),
source: closestBlock.id,
target: node.id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
} else {
// No children: connect from the container's start handle to the moved node
const containerNode = getNodes().find((n) => n.id === potentialParentId)
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
addEdge({
id: crypto.randomUUID(),
source: potentialParentId,
target: node.id,
sourceHandle: startSourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
}
// Reset state
@@ -1411,6 +1511,10 @@ const WorkflowContent = React.memo(() => {
updateNodeParent,
getNodeHierarchyWrapper,
collaborativeUpdateBlockPosition,
addEdge,
determineSourceHandle,
blocks,
getNodeAbsolutePositionWrapper,
]
)

View File

@@ -0,0 +1,67 @@
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
import { NavigationItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/navigation-item/navigation-item'
import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
interface FloatingNavigationProps {
workspaceId: string
pathname: string
onShowSettings: () => void
onShowHelp: () => void
bottom: number
}
export const FloatingNavigation = ({
workspaceId,
pathname,
onShowSettings,
onShowHelp,
bottom,
}: FloatingNavigationProps) => {
// Navigation items with their respective actions
const navigationItems = [
{
id: 'settings',
icon: Settings,
onClick: onShowSettings,
tooltip: 'Settings',
},
{
id: 'help',
icon: HelpCircle,
onClick: onShowHelp,
tooltip: 'Help',
},
{
id: 'logs',
icon: ScrollText,
href: `/workspace/${workspaceId}/logs`,
tooltip: 'Logs',
shortcut: getKeyboardShortcutText('L', true, true),
active: pathname === `/workspace/${workspaceId}/logs`,
},
{
id: 'knowledge',
icon: LibraryBig,
href: `/workspace/${workspaceId}/knowledge`,
tooltip: 'Knowledge',
active: pathname === `/workspace/${workspaceId}/knowledge`,
},
{
id: 'templates',
icon: Shapes,
href: `/workspace/${workspaceId}/templates`,
tooltip: 'Templates',
active: pathname === `/workspace/${workspaceId}/templates`,
},
]
return (
<div className='pointer-events-auto fixed left-4 z-50 w-56' style={{ bottom: `${bottom}px` }}>
<div className='flex items-center gap-1'>
{navigationItems.map((item) => (
<NavigationItem key={item.id} item={item} />
))}
</div>
</div>
)
}

View File

@@ -1,9 +1,12 @@
export { CreateMenu } from './create-menu/create-menu'
export { FloatingNavigation } from './floating-navigation/floating-navigation'
export { FolderTree } from './folder-tree/folder-tree'
export { HelpModal } from './help-modal/help-modal'
export { KeyboardShortcut } from './keyboard-shortcut/keyboard-shortcut'
export { KnowledgeBaseTags } from './knowledge-base-tags/knowledge-base-tags'
export { KnowledgeTags } from './knowledge-tags/knowledge-tags'
export { LogsFilters } from './logs-filters/logs-filters'
export { NavigationItem } from './navigation-item/navigation-item'
export { SettingsModal } from './settings-modal/settings-modal'
export { SubscriptionModal } from './subscription-modal/subscription-modal'
export { Toolbar } from './toolbar/toolbar'

View File

@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils'
interface KeyboardShortcutProps {
shortcut: string
className?: string
}
export const KeyboardShortcut = ({ shortcut, className }: KeyboardShortcutProps) => {
const parts = shortcut.split('+')
// Helper function to determine if a part is a symbol that should be larger
const isSymbol = (part: string) => {
return ['⌘', '⇧', '⌥', '⌃'].includes(part)
}
return (
<kbd
className={cn(
'flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]',
className
)}
>
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
{parts.map((part, index) => (
<span key={index} className={cn(isSymbol(part) ? 'text-[17px]' : 'text-xs')}>
{part}
</span>
))}
</span>
</kbd>
)
}

View File

@@ -0,0 +1,64 @@
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { cn } from '@/lib/utils'
interface NavigationItemProps {
item: {
id: string
icon: React.ElementType
onClick?: () => void
href?: string
tooltip: string
shortcut?: string
active?: boolean
disabled?: boolean
}
}
export const NavigationItem = ({ item }: NavigationItemProps) => {
// Settings and help buttons get gray hover, others get purple hover
const isGrayHover = item.id === 'settings' || item.id === 'help'
const content = item.disabled ? (
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<item.icon className='h-4 w-4' />
</div>
) : (
<Button
variant='outline'
onClick={item.onClick}
className={cn(
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary',
!isGrayHover &&
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
item.active && 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)] text-white'
)}
>
<item.icon className='h-4 w-4' />
</Button>
)
if (item.href && !item.disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a href={item.href} className='inline-block'>
{content}
</a>
</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -44,7 +44,7 @@ interface WorkspaceSelectorProps {
onWorkspaceUpdate: () => Promise<void>
onSwitchWorkspace: (workspace: Workspace) => Promise<void>
onCreateWorkspace: () => Promise<void>
onDeleteWorkspace: (workspace: Workspace) => Promise<void>
onDeleteWorkspace: (workspace: Workspace, templateAction?: 'keep' | 'delete') => Promise<void>
onLeaveWorkspace: (workspace: Workspace) => Promise<void>
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
isDeleting: boolean
@@ -76,6 +76,14 @@ export function WorkspaceSelector({
const [isRenaming, setIsRenaming] = useState(false)
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
const [leaveConfirmationName, setLeaveConfirmationName] = useState('')
const [isCheckingTemplates, setIsCheckingTemplates] = useState(false)
const [showTemplateChoice, setShowTemplateChoice] = useState(false)
const [templatesInfo, setTemplatesInfo] = useState<{
count: number
templates: Array<{ id: string; name: string }>
} | null>(null)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [workspaceToDelete, setWorkspaceToDelete] = useState<Workspace | null>(null)
// Refs
const scrollAreaRef = useRef<HTMLDivElement>(null)
@@ -206,15 +214,82 @@ export function WorkspaceSelector({
)
/**
* Confirm delete workspace
* Reset delete dialog state
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace) => {
await onDeleteWorkspace(workspaceToDelete)
const resetDeleteState = useCallback(() => {
setDeleteConfirmationName('')
setShowTemplateChoice(false)
setTemplatesInfo(null)
setIsCheckingTemplates(false)
setWorkspaceToDelete(null)
}, [])
/**
* Handle dialog close
*/
const handleDialogClose = useCallback(
(open: boolean) => {
if (!open) {
resetDeleteState()
}
setIsDeleteDialogOpen(open)
},
[onDeleteWorkspace]
[resetDeleteState]
)
/**
* Handle template choice action
*/
const handleTemplateAction = useCallback(
async (action: 'keep' | 'delete') => {
if (!workspaceToDelete) return
setShowTemplateChoice(false)
setTemplatesInfo(null)
setDeleteConfirmationName('')
await onDeleteWorkspace(workspaceToDelete, action)
setWorkspaceToDelete(null)
setIsDeleteDialogOpen(false)
},
[workspaceToDelete, onDeleteWorkspace]
)
/**
* Check for templates and handle deletion
*/
const handleDeleteClick = useCallback(async () => {
if (!workspaceToDelete) return
setIsCheckingTemplates(true)
try {
const checkResponse = await fetch(
`/api/workspaces/${workspaceToDelete.id}?check-templates=true`
)
if (checkResponse.ok) {
const templateCheck = await checkResponse.json()
if (templateCheck.hasPublishedTemplates && templateCheck.count > 0) {
// Templates exist - show template choice
setTemplatesInfo({
count: templateCheck.count,
templates: templateCheck.publishedTemplates,
})
setShowTemplateChoice(true)
setIsCheckingTemplates(false)
return
}
}
} catch (error) {
logger.error('Error checking templates:', error)
}
// No templates or error - proceed with deletion
setIsCheckingTemplates(false)
setDeleteConfirmationName('')
await onDeleteWorkspace(workspaceToDelete)
setWorkspaceToDelete(null)
setIsDeleteDialogOpen(false)
}, [workspaceToDelete, onDeleteWorkspace])
/**
* Confirm leave workspace
*/
@@ -352,7 +427,7 @@ export function WorkspaceSelector({
<Input
value={leaveConfirmationName}
onChange={(e) => setLeaveConfirmationName(e.target.value)}
placeholder='Placeholder'
placeholder={workspace.name}
className='h-9'
/>
</div>
@@ -381,66 +456,21 @@ export function WorkspaceSelector({
{/* Delete Workspace - for admin users */}
{workspace.permissions === 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => e.stopPropagation()}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<Trash2 className='!h-3.5 !w-3.5' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this workspace will permanently remove all associated workflows,
logs, and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name{' '}
<span className='font-semibold'>{workspace.name}</span> to confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder='Placeholder'
className='h-9 rounded-[8px]'
/>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setDeleteConfirmationName('')}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
confirmDeleteWorkspace(workspace)
setDeleteConfirmationName('')
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isDeleting || deleteConfirmationName !== workspace.name}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
setWorkspaceToDelete(workspace)
setIsDeleteDialogOpen(true)
}}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<Trash2 className='!h-3.5 !w-3.5' />
</Button>
)}
</div>
</div>
@@ -496,6 +526,106 @@ export function WorkspaceSelector({
</div>
</div>
{/* Centralized Delete Workspace Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={handleDialogClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{showTemplateChoice
? 'Delete workspace with published templates?'
: 'Delete workspace?'}
</AlertDialogTitle>
<AlertDialogDescription>
{showTemplateChoice ? (
<>
This workspace contains {templatesInfo?.count} published template
{templatesInfo?.count === 1 ? '' : 's'}:
<br />
<br />
{templatesInfo?.templates.map((template) => (
<span key={template.id} className='block'>
{template.name}
</span>
))}
<br />
What would you like to do with the published templates?
</>
) : (
<>
Deleting this workspace will permanently remove all associated workflows, logs,
and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{showTemplateChoice ? (
<div className='flex gap-2 py-2'>
<Button
onClick={() => handleTemplateAction('keep')}
className='h-9 flex-1 rounded-[8px]'
variant='outline'
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Keep Templates'}
</Button>
<Button
onClick={() => handleTemplateAction('delete')}
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Templates'}
</Button>
</div>
) : (
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name{' '}
<span className='font-semibold'>{workspaceToDelete?.name}</span> to confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder={workspaceToDelete?.name}
className='h-9 rounded-[8px]'
/>
</div>
)}
{!showTemplateChoice && (
<AlertDialogFooter className='flex'>
<Button
variant='outline'
className='h-9 w-full rounded-[8px]'
onClick={() => {
resetDeleteState()
setIsDeleteDialogOpen(false)
}}
>
Cancel
</Button>
<Button
onClick={(e) => {
e.preventDefault()
handleDeleteClick()
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={
isDeleting ||
deleteConfirmationName !== workspaceToDelete?.name ||
isCheckingTemplates
}
>
{isDeleting ? 'Deleting...' : isCheckingTemplates ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
)}
</AlertDialogContent>
</AlertDialog>
{/* Invite Modal */}
<InviteModal
open={showInviteMembers}

View File

@@ -1,20 +1,21 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lucide-react'
import { Search } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateWorkspaceName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/search-modal/search-modal'
import {
CreateMenu,
FloatingNavigation,
FolderTree,
HelpModal,
KeyboardShortcut,
KnowledgeBaseTags,
KnowledgeTags,
LogsFilters,
@@ -26,6 +27,7 @@ import {
WorkspaceSelector,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/hooks/use-auto-scroll'
import {
getKeyboardShortcutText,
useGlobalShortcuts,
@@ -40,109 +42,6 @@ const SIDEBAR_GAP = 12 // 12px gap between components - easily editable
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
/**
* Optimized auto-scroll hook for smooth drag operations
* Extracted outside component for better performance
*/
const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const animationRef = useRef<number | null>(null)
const speedRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const animateScroll = useCallback(() => {
const scrollContainer = containerRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (!scrollContainer || speedRef.current === 0) {
animationRef.current = null
return
}
const currentScrollTop = scrollContainer.scrollTop
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
// Check bounds and stop if needed
if (
(speedRef.current < 0 && currentScrollTop <= 0) ||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
) {
speedRef.current = 0
animationRef.current = null
return
}
// Apply smooth scroll
scrollContainer.scrollTop = Math.max(
0,
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
)
animationRef.current = requestAnimationFrame(animateScroll)
}, [containerRef])
const startScroll = useCallback(
(speed: number) => {
speedRef.current = speed
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(animateScroll)
}
},
[animateScroll]
)
const stopScroll = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
speedRef.current = 0
}, [])
const handleDragOver = useCallback(
(e: DragEvent) => {
const now = performance.now()
// Throttle to ~16ms for 60fps
if (now - lastUpdateRef.current < 16) return
lastUpdateRef.current = now
const scrollContainer = containerRef.current
if (!scrollContainer) return
const rect = scrollContainer.getBoundingClientRect()
const mouseY = e.clientY
// Early exit if mouse is outside container
if (mouseY < rect.top || mouseY > rect.bottom) {
stopScroll()
return
}
const scrollZone = 50
const maxSpeed = 4
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollSpeed = 0
if (distanceFromTop < scrollZone) {
const intensity = (scrollZone - distanceFromTop) / scrollZone
scrollSpeed = -maxSpeed * intensity ** 2
} else if (distanceFromBottom < scrollZone) {
const intensity = (scrollZone - distanceFromBottom) / scrollZone
scrollSpeed = maxSpeed * intensity ** 2
}
if (Math.abs(scrollSpeed) > 0.1) {
startScroll(scrollSpeed)
} else {
stopScroll()
}
},
[containerRef, startScroll, stopScroll]
)
return { handleDragOver, stopScroll }
}
// Heights for dynamic calculation (in px)
const SIDEBAR_HEIGHTS = {
CONTAINER_PADDING: 32, // p-4 = 16px top + 16px bottom (bottom provides control bar spacing match)
@@ -204,6 +103,7 @@ export function Sidebar() {
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false)
// Add sidebar collapsed state
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string
@@ -509,16 +409,22 @@ export function Sidebar() {
}, [refreshWorkspaceList, switchWorkspace, isCreatingWorkspace])
/**
* Confirm delete workspace
* Confirm delete workspace (called from regular deletion dialog)
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace) => {
async (workspaceToDelete: Workspace, templateAction?: 'keep' | 'delete') => {
setIsDeleting(true)
try {
logger.info('Deleting workspace:', workspaceToDelete.id)
const deleteTemplates = templateAction === 'delete'
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ deleteTemplates }),
})
if (!response.ok) {
@@ -961,44 +867,6 @@ export function Sidebar() {
}
}, [stopScroll])
// Navigation items with their respective actions
const navigationItems = [
{
id: 'settings',
icon: Settings,
onClick: () => setShowSettings(true),
tooltip: 'Settings',
},
{
id: 'help',
icon: HelpCircle,
onClick: () => setShowHelp(true),
tooltip: 'Help',
},
{
id: 'logs',
icon: ScrollText,
href: `/workspace/${workspaceId}/logs`,
tooltip: 'Logs',
shortcut: getKeyboardShortcutText('L', true, true),
active: pathname === `/workspace/${workspaceId}/logs`,
},
{
id: 'knowledge',
icon: LibraryBig,
href: `/workspace/${workspaceId}/knowledge`,
tooltip: 'Knowledge',
active: pathname === `/workspace/${workspaceId}/knowledge`,
},
{
id: 'templates',
icon: Shapes,
href: `/workspace/${workspaceId}/templates`,
tooltip: 'Templates',
active: pathname === `/workspace/${workspaceId}/templates`,
},
]
return (
<>
{/* Main Sidebar - Overlay */}
@@ -1155,16 +1023,13 @@ export function Sidebar() {
)}
{/* Floating Navigation - Always visible */}
<div
className='pointer-events-auto fixed left-4 z-50 w-56'
style={{ bottom: `${navigationBottom}px` }}
>
<div className='flex items-center gap-1'>
{navigationItems.map((item) => (
<NavigationItem key={item.id} item={item} />
))}
</div>
</div>
<FloatingNavigation
workspaceId={workspaceId}
pathname={pathname}
onShowSettings={() => setShowSettings(true)}
onShowHelp={() => setShowHelp(true)}
bottom={navigationBottom}
/>
{/* Modals */}
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
@@ -1183,98 +1048,3 @@ export function Sidebar() {
</>
)
}
// Keyboard Shortcut Component
interface KeyboardShortcutProps {
shortcut: string
className?: string
}
const KeyboardShortcut = ({ shortcut, className }: KeyboardShortcutProps) => {
const parts = shortcut.split('+')
// Helper function to determine if a part is a symbol that should be larger
const isSymbol = (part: string) => {
return ['⌘', '⇧', '⌥', '⌃'].includes(part)
}
return (
<kbd
className={cn(
'flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]',
className
)}
>
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
{parts.map((part, index) => (
<span key={index} className={cn(isSymbol(part) ? 'text-[17px]' : 'text-xs')}>
{part}
</span>
))}
</span>
</kbd>
)
}
// Navigation Item Component
interface NavigationItemProps {
item: {
id: string
icon: React.ElementType
onClick?: () => void
href?: string
tooltip: string
shortcut?: string
active?: boolean
disabled?: boolean
}
}
const NavigationItem = ({ item }: NavigationItemProps) => {
// Settings and help buttons get gray hover, others get purple hover
const isGrayHover = item.id === 'settings' || item.id === 'help'
const content = item.disabled ? (
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<item.icon className='h-4 w-4' />
</div>
) : (
<Button
variant='outline'
onClick={item.onClick}
className={cn(
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary',
!isGrayHover &&
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
item.active && 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)] text-white'
)}
>
<item.icon className='h-4 w-4' />
</Button>
)
if (item.href && !item.disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a href={item.href} className='inline-block'>
{content}
</a>
</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -0,0 +1,103 @@
import { useCallback, useRef } from 'react'
/**
* Optimized auto-scroll hook for smooth drag operations
*/
export const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const animationRef = useRef<number | null>(null)
const speedRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const animateScroll = useCallback(() => {
const scrollContainer = containerRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (!scrollContainer || speedRef.current === 0) {
animationRef.current = null
return
}
const currentScrollTop = scrollContainer.scrollTop
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
// Check bounds and stop if needed
if (
(speedRef.current < 0 && currentScrollTop <= 0) ||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
) {
speedRef.current = 0
animationRef.current = null
return
}
// Apply smooth scroll
scrollContainer.scrollTop = Math.max(
0,
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
)
animationRef.current = requestAnimationFrame(animateScroll)
}, [containerRef])
const startScroll = useCallback(
(speed: number) => {
speedRef.current = speed
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(animateScroll)
}
},
[animateScroll]
)
const stopScroll = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
speedRef.current = 0
}, [])
const handleDragOver = useCallback(
(e: DragEvent) => {
const now = performance.now()
// Throttle to ~16ms for 60fps
if (now - lastUpdateRef.current < 16) return
lastUpdateRef.current = now
const scrollContainer = containerRef.current
if (!scrollContainer) return
const rect = scrollContainer.getBoundingClientRect()
const mouseY = e.clientY
// Early exit if mouse is outside container
if (mouseY < rect.top || mouseY > rect.bottom) {
stopScroll()
return
}
const scrollZone = 50
const maxSpeed = 4
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollSpeed = 0
if (distanceFromTop < scrollZone) {
const intensity = (scrollZone - distanceFromTop) / scrollZone
scrollSpeed = -maxSpeed * intensity ** 2
} else if (distanceFromBottom < scrollZone) {
const intensity = (scrollZone - distanceFromBottom) / scrollZone
scrollSpeed = maxSpeed * intensity ** 2
}
if (Math.abs(scrollSpeed) > 0.1) {
startScroll(scrollSpeed)
} else {
stopScroll()
}
},
[containerRef, startScroll, stopScroll]
)
return { handleDragOver, stopScroll }
}

View File

@@ -13,7 +13,7 @@ export default function WorkspaceRootLayout({ children }: WorkspaceRootLayoutPro
const user = session.data?.user
? {
id: session.data.user.id,
name: session.data.user.name,
name: session.data.user.name ?? undefined,
email: session.data.user.email,
}
: undefined

View File

@@ -17,362 +17,363 @@ import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('TriggerWebhookExecution')
export type WebhookExecutionPayload = {
webhookId: string
workflowId: string
userId: string
provider: string
body: any
headers: Record<string, string>
path: string
blockId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
const executionId = uuidv4()
const requestId = executionId.slice(0, 8)
logger.info(`[${requestId}] Starting webhook execution`, {
webhookId: payload.webhookId,
workflowId: payload.workflowId,
provider: payload.provider,
userId: payload.userId,
executionId,
})
// Initialize logging session outside try block so it's available in catch
const loggingSession = new LoggingSession(payload.workflowId, executionId, 'webhook', requestId)
try {
// Check usage limits first
const usageCheck = await checkServerSideUsageLimits(payload.userId)
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${payload.userId} has exceeded usage limits. Skipping webhook execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId: payload.workflowId,
}
)
throw new Error(
usageCheck.message ||
'Usage limit exceeded. Please upgrade your plan to continue using webhooks.'
)
}
// Load workflow from normalized tables
const workflowData = await loadWorkflowFromNormalizedTables(payload.workflowId)
if (!workflowData) {
throw new Error(`Workflow not found: ${payload.workflowId}`)
}
const { blocks, edges, loops, parallels } = workflowData
// Get environment variables (matching workflow-execution pattern)
const [userEnv] = await db
.select()
.from(environmentTable)
.where(eq(environmentTable.userId, payload.userId))
.limit(1)
let decryptedEnvVars: Record<string, string> = {}
if (userEnv) {
const decryptionPromises = Object.entries((userEnv.variables as any) || {}).map(
async ([key, encryptedValue]) => {
try {
const { decrypted } = await decryptSecret(encryptedValue as string)
return [key, decrypted] as const
} catch (error: any) {
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}":`, error)
throw new Error(`Failed to decrypt environment variable "${key}": ${error.message}`)
}
}
)
const decryptedPairs = await Promise.all(decryptionPromises)
decryptedEnvVars = Object.fromEntries(decryptedPairs)
}
// Start logging session
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: '', // TODO: Get from workflow if needed
variables: decryptedEnvVars,
})
// Merge subblock states (matching workflow-execution pattern)
const mergedStates = mergeSubblockState(blocks, {})
// Process block states for execution
const processedBlockStates = Object.entries(mergedStates).reduce(
(acc, [blockId, blockState]) => {
acc[blockId] = Object.entries(blockState.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Handle workflow variables (for now, use empty object since we don't have workflow metadata)
const workflowVariables = {}
// Create serialized workflow
const serializer = new Serializer()
const serializedWorkflow = serializer.serializeWorkflow(
mergedStates,
edges,
loops || {},
parallels || {},
true // Enable validation during execution
)
// Handle special Airtable case
if (payload.provider === 'airtable') {
logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`)
// Load the actual webhook record from database to get providerConfig
const [webhookRecord] = await db
.select()
.from(webhook)
.where(eq(webhook.id, payload.webhookId))
.limit(1)
if (!webhookRecord) {
throw new Error(`Webhook record not found: ${payload.webhookId}`)
}
const webhookData = {
id: payload.webhookId,
provider: payload.provider,
providerConfig: webhookRecord.providerConfig,
}
// Create a mock workflow object for Airtable processing
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
}
// Get the processed Airtable input
const airtableInput = await fetchAndProcessAirtablePayloads(
webhookData,
mockWorkflow,
requestId
)
// If we got input (changes), execute the workflow like other providers
if (airtableInput) {
logger.info(`[${requestId}] Executing workflow with Airtable changes`)
// Create executor and execute (same as standard webhook flow)
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: airtableInput,
workflowVariables,
contextExtensions: {
executionId,
workspaceId: '',
},
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
// Execute the workflow
const result = await executor.execute(payload.workflowId, payload.blockId)
// Check if we got a StreamingExecution result
const executionResult =
'stream' in result && 'execution' in result ? result.execution : result
logger.info(`[${requestId}] Airtable webhook execution completed`, {
success: executionResult.success,
workflowId: payload.workflowId,
})
// Update workflow run counts on success
if (executionResult.success) {
await updateWorkflowRunCounts(payload.workflowId)
// Track execution in user stats
await db
.update(userStats)
.set({
totalWebhookTriggers: sql`total_webhook_triggers + 1`,
lastActive: sql`now()`,
})
.where(eq(userStats.userId, payload.userId))
}
// Build trace spans and complete logging session
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
})
return {
success: executionResult.success,
workflowId: payload.workflowId,
executionId,
output: executionResult.output,
executedAt: new Date().toISOString(),
provider: payload.provider,
}
}
// No changes to process
logger.info(`[${requestId}] No Airtable changes to process`)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
finalOutput: { message: 'No Airtable changes to process' },
traceSpans: [],
})
return {
success: true,
workflowId: payload.workflowId,
executionId,
output: { message: 'No Airtable changes to process' },
executedAt: new Date().toISOString(),
}
}
// Format input for standard webhooks
const mockWebhook = {
provider: payload.provider,
blockId: payload.blockId,
}
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
}
const mockRequest = {
headers: new Map(Object.entries(payload.headers)),
} as any
const input = formatWebhookInput(mockWebhook, mockWorkflow, payload.body, mockRequest)
if (!input && payload.provider === 'whatsapp') {
logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
finalOutput: { message: 'No messages in WhatsApp payload' },
traceSpans: [],
})
return {
success: true,
workflowId: payload.workflowId,
executionId,
output: { message: 'No messages in WhatsApp payload' },
executedAt: new Date().toISOString(),
}
}
// Create executor and execute
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: input || {},
workflowVariables,
contextExtensions: {
executionId,
workspaceId: '', // TODO: Get from workflow if needed - see comment on line 103
},
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`)
// Execute the workflow
const result = await executor.execute(payload.workflowId, payload.blockId)
// Check if we got a StreamingExecution result
const executionResult = 'stream' in result && 'execution' in result ? result.execution : result
logger.info(`[${requestId}] Webhook execution completed`, {
success: executionResult.success,
workflowId: payload.workflowId,
provider: payload.provider,
})
// Update workflow run counts on success
if (executionResult.success) {
await updateWorkflowRunCounts(payload.workflowId)
// Track execution in user stats
await db
.update(userStats)
.set({
totalWebhookTriggers: sql`total_webhook_triggers + 1`,
lastActive: sql`now()`,
})
.where(eq(userStats.userId, payload.userId))
}
// Build trace spans and complete logging session
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
})
return {
success: executionResult.success,
workflowId: payload.workflowId,
executionId,
output: executionResult.output,
executedAt: new Date().toISOString(),
provider: payload.provider,
}
} catch (error: any) {
logger.error(`[${requestId}] Webhook execution failed`, {
error: error.message,
stack: error.stack,
workflowId: payload.workflowId,
provider: payload.provider,
})
// Complete logging session with error (matching workflow-execution pattern)
try {
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Webhook execution failed',
stackTrace: error.stack,
},
})
} catch (loggingError) {
logger.error(`[${requestId}] Failed to complete logging session`, loggingError)
}
throw error
}
}
export const webhookExecution = task({
id: 'webhook-execution',
retry: {
maxAttempts: 1,
},
run: async (payload: {
webhookId: string
workflowId: string
userId: string
provider: string
body: any
headers: Record<string, string>
path: string
blockId?: string
}) => {
const executionId = uuidv4()
const requestId = executionId.slice(0, 8)
logger.info(`[${requestId}] Starting webhook execution via trigger.dev`, {
webhookId: payload.webhookId,
workflowId: payload.workflowId,
provider: payload.provider,
userId: payload.userId,
executionId,
})
// Initialize logging session outside try block so it's available in catch
const loggingSession = new LoggingSession(payload.workflowId, executionId, 'webhook', requestId)
try {
// Check usage limits first
const usageCheck = await checkServerSideUsageLimits(payload.userId)
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${payload.userId} has exceeded usage limits. Skipping webhook execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId: payload.workflowId,
}
)
throw new Error(
usageCheck.message ||
'Usage limit exceeded. Please upgrade your plan to continue using webhooks.'
)
}
// Load workflow from normalized tables
const workflowData = await loadWorkflowFromNormalizedTables(payload.workflowId)
if (!workflowData) {
throw new Error(`Workflow not found: ${payload.workflowId}`)
}
const { blocks, edges, loops, parallels } = workflowData
// Get environment variables (matching workflow-execution pattern)
const [userEnv] = await db
.select()
.from(environmentTable)
.where(eq(environmentTable.userId, payload.userId))
.limit(1)
let decryptedEnvVars: Record<string, string> = {}
if (userEnv) {
const decryptionPromises = Object.entries((userEnv.variables as any) || {}).map(
async ([key, encryptedValue]) => {
try {
const { decrypted } = await decryptSecret(encryptedValue as string)
return [key, decrypted] as const
} catch (error: any) {
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}":`, error)
throw new Error(`Failed to decrypt environment variable "${key}": ${error.message}`)
}
}
)
const decryptedPairs = await Promise.all(decryptionPromises)
decryptedEnvVars = Object.fromEntries(decryptedPairs)
}
// Start logging session
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: '', // TODO: Get from workflow if needed
variables: decryptedEnvVars,
})
// Merge subblock states (matching workflow-execution pattern)
const mergedStates = mergeSubblockState(blocks, {})
// Process block states for execution
const processedBlockStates = Object.entries(mergedStates).reduce(
(acc, [blockId, blockState]) => {
acc[blockId] = Object.entries(blockState.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Handle workflow variables (for now, use empty object since we don't have workflow metadata)
const workflowVariables = {}
// Create serialized workflow
const serializer = new Serializer()
const serializedWorkflow = serializer.serializeWorkflow(
mergedStates,
edges,
loops || {},
parallels || {},
true // Enable validation during execution
)
// Handle special Airtable case
if (payload.provider === 'airtable') {
logger.info(
`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`
)
// Load the actual webhook record from database to get providerConfig
const [webhookRecord] = await db
.select()
.from(webhook)
.where(eq(webhook.id, payload.webhookId))
.limit(1)
if (!webhookRecord) {
throw new Error(`Webhook record not found: ${payload.webhookId}`)
}
const webhookData = {
id: payload.webhookId,
provider: payload.provider,
providerConfig: webhookRecord.providerConfig,
}
// Create a mock workflow object for Airtable processing
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
}
// Get the processed Airtable input
const airtableInput = await fetchAndProcessAirtablePayloads(
webhookData,
mockWorkflow,
requestId
)
// If we got input (changes), execute the workflow like other providers
if (airtableInput) {
logger.info(`[${requestId}] Executing workflow with Airtable changes`)
// Create executor and execute (same as standard webhook flow)
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: airtableInput,
workflowVariables,
contextExtensions: {
executionId,
workspaceId: '',
},
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
// Execute the workflow
const result = await executor.execute(payload.workflowId, payload.blockId)
// Check if we got a StreamingExecution result
const executionResult =
'stream' in result && 'execution' in result ? result.execution : result
logger.info(`[${requestId}] Airtable webhook execution completed`, {
success: executionResult.success,
workflowId: payload.workflowId,
})
// Update workflow run counts on success
if (executionResult.success) {
await updateWorkflowRunCounts(payload.workflowId)
// Track execution in user stats
await db
.update(userStats)
.set({
totalWebhookTriggers: sql`total_webhook_triggers + 1`,
lastActive: sql`now()`,
})
.where(eq(userStats.userId, payload.userId))
}
// Build trace spans and complete logging session
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
})
return {
success: executionResult.success,
workflowId: payload.workflowId,
executionId,
output: executionResult.output,
executedAt: new Date().toISOString(),
provider: payload.provider,
}
}
// No changes to process
logger.info(`[${requestId}] No Airtable changes to process`)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
finalOutput: { message: 'No Airtable changes to process' },
traceSpans: [],
})
return {
success: true,
workflowId: payload.workflowId,
executionId,
output: { message: 'No Airtable changes to process' },
executedAt: new Date().toISOString(),
}
}
// Format input for standard webhooks
const mockWebhook = {
provider: payload.provider,
blockId: payload.blockId,
}
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
}
const mockRequest = {
headers: new Map(Object.entries(payload.headers)),
} as any
const input = formatWebhookInput(mockWebhook, mockWorkflow, payload.body, mockRequest)
if (!input && payload.provider === 'whatsapp') {
logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
finalOutput: { message: 'No messages in WhatsApp payload' },
traceSpans: [],
})
return {
success: true,
workflowId: payload.workflowId,
executionId,
output: { message: 'No messages in WhatsApp payload' },
executedAt: new Date().toISOString(),
}
}
// Create executor and execute
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: input || {},
workflowVariables,
contextExtensions: {
executionId,
workspaceId: '', // TODO: Get from workflow if needed - see comment on line 103
},
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`)
// Execute the workflow
const result = await executor.execute(payload.workflowId, payload.blockId)
// Check if we got a StreamingExecution result
const executionResult =
'stream' in result && 'execution' in result ? result.execution : result
logger.info(`[${requestId}] Webhook execution completed`, {
success: executionResult.success,
workflowId: payload.workflowId,
provider: payload.provider,
})
// Update workflow run counts on success
if (executionResult.success) {
await updateWorkflowRunCounts(payload.workflowId)
// Track execution in user stats
await db
.update(userStats)
.set({
totalWebhookTriggers: sql`total_webhook_triggers + 1`,
lastActive: sql`now()`,
})
.where(eq(userStats.userId, payload.userId))
}
// Build trace spans and complete logging session
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
})
return {
success: executionResult.success,
workflowId: payload.workflowId,
executionId,
output: executionResult.output,
executedAt: new Date().toISOString(),
provider: payload.provider,
}
} catch (error: any) {
logger.error(`[${requestId}] Webhook execution failed`, {
error: error.message,
stack: error.stack,
workflowId: payload.workflowId,
provider: payload.provider,
})
// Complete logging session with error (matching workflow-execution pattern)
try {
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Webhook execution failed',
stackTrace: error.stack,
},
})
} catch (loggingError) {
logger.error(`[${requestId}] Failed to complete logging session`, loggingError)
}
throw error // Let Trigger.dev handle retries
}
},
run: async (payload: WebhookExecutionPayload) => executeWebhookJob(payload),
})

View File

@@ -16,200 +16,202 @@ import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('TriggerWorkflowExecution')
export type WorkflowExecutionPayload = {
workflowId: string
userId: string
input?: any
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
metadata?: Record<string, any>
}
export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
const workflowId = payload.workflowId
const executionId = uuidv4()
const requestId = executionId.slice(0, 8)
logger.info(`[${requestId}] Starting workflow execution: ${workflowId}`, {
userId: payload.userId,
triggerType: payload.triggerType,
executionId,
})
// Initialize logging session
const triggerType = payload.triggerType || 'api'
const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId)
try {
const usageCheck = await checkServerSideUsageLimits(payload.userId)
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${payload.userId} has exceeded usage limits. Skipping workflow execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId: payload.workflowId,
}
)
throw new Error(
usageCheck.message ||
'Usage limit exceeded. Please upgrade your plan to continue using workflows.'
)
}
// Load workflow data from deployed state (this task is only used for API executions right now)
const workflowData = await loadDeployedWorkflowState(workflowId)
const { blocks, edges, loops, parallels } = workflowData
// Merge subblock states (server-safe version doesn't need workflowId)
const mergedStates = mergeSubblockState(blocks, {})
// Process block states for execution
const processedBlockStates = Object.entries(mergedStates).reduce(
(acc, [blockId, blockState]) => {
acc[blockId] = Object.entries(blockState.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Get environment variables
const [userEnv] = await db
.select()
.from(environmentTable)
.where(eq(environmentTable.userId, payload.userId))
.limit(1)
let decryptedEnvVars: Record<string, string> = {}
if (userEnv) {
const decryptionPromises = Object.entries((userEnv.variables as any) || {}).map(
async ([key, encryptedValue]) => {
try {
const { decrypted } = await decryptSecret(encryptedValue as string)
return [key, decrypted] as const
} catch (error: any) {
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}":`, error)
throw new Error(`Failed to decrypt environment variable "${key}": ${error.message}`)
}
}
)
const decryptedPairs = await Promise.all(decryptionPromises)
decryptedEnvVars = Object.fromEntries(decryptedPairs)
}
// Start logging session
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: '', // TODO: Get from workflow if needed
variables: decryptedEnvVars,
})
// Create serialized workflow
const serializer = new Serializer()
const serializedWorkflow = serializer.serializeWorkflow(
mergedStates,
edges,
loops || {},
parallels || {},
true // Enable validation during execution
)
// Create executor and execute
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: payload.input || {},
workflowVariables: {},
contextExtensions: {
executionId,
workspaceId: '', // TODO: Get from workflow if needed - see comment on line 120
},
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
const result = await executor.execute(workflowId)
// Handle streaming vs regular result
const executionResult = 'stream' in result && 'execution' in result ? result.execution : result
logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, {
success: executionResult.success,
executionTime: executionResult.metadata?.duration,
executionId,
})
// Update workflow run counts on success
if (executionResult.success) {
await updateWorkflowRunCounts(workflowId)
// Track execution in user stats
const statsUpdate =
triggerType === 'api'
? { totalApiCalls: sql`total_api_calls + 1` }
: triggerType === 'webhook'
? { totalWebhookTriggers: sql`total_webhook_triggers + 1` }
: triggerType === 'schedule'
? { totalScheduledExecutions: sql`total_scheduled_executions + 1` }
: { totalManualExecutions: sql`total_manual_executions + 1` }
await db
.update(userStats)
.set({
...statsUpdate,
lastActive: sql`now()`,
})
.where(eq(userStats.userId, payload.userId))
}
// Build trace spans and complete logging session (for both success and failure)
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
})
return {
success: executionResult.success,
workflowId: payload.workflowId,
executionId,
output: executionResult.output,
executedAt: new Date().toISOString(),
metadata: payload.metadata,
}
} catch (error: any) {
logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, {
error: error.message,
stack: error.stack,
})
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Workflow execution failed',
stackTrace: error.stack,
},
})
throw error
}
}
export const workflowExecution = task({
id: 'workflow-execution',
retry: {
maxAttempts: 1,
},
run: async (payload: {
workflowId: string
userId: string
input?: any
triggerType?: string
metadata?: Record<string, any>
}) => {
const workflowId = payload.workflowId
const executionId = uuidv4()
const requestId = executionId.slice(0, 8)
logger.info(`[${requestId}] Starting Trigger.dev workflow execution: ${workflowId}`, {
userId: payload.userId,
triggerType: payload.triggerType,
executionId,
})
// Initialize logging session
const triggerType =
(payload.triggerType as 'api' | 'webhook' | 'schedule' | 'manual' | 'chat') || 'api'
const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId)
try {
const usageCheck = await checkServerSideUsageLimits(payload.userId)
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${payload.userId} has exceeded usage limits. Skipping workflow execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId: payload.workflowId,
}
)
throw new Error(
usageCheck.message ||
'Usage limit exceeded. Please upgrade your plan to continue using workflows.'
)
}
// Load workflow data from deployed state (this task is only used for API executions right now)
const workflowData = await loadDeployedWorkflowState(workflowId)
const { blocks, edges, loops, parallels } = workflowData
// Merge subblock states (server-safe version doesn't need workflowId)
const mergedStates = mergeSubblockState(blocks, {})
// Process block states for execution
const processedBlockStates = Object.entries(mergedStates).reduce(
(acc, [blockId, blockState]) => {
acc[blockId] = Object.entries(blockState.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Get environment variables
const [userEnv] = await db
.select()
.from(environmentTable)
.where(eq(environmentTable.userId, payload.userId))
.limit(1)
let decryptedEnvVars: Record<string, string> = {}
if (userEnv) {
const decryptionPromises = Object.entries((userEnv.variables as any) || {}).map(
async ([key, encryptedValue]) => {
try {
const { decrypted } = await decryptSecret(encryptedValue as string)
return [key, decrypted] as const
} catch (error: any) {
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}":`, error)
throw new Error(`Failed to decrypt environment variable "${key}": ${error.message}`)
}
}
)
const decryptedPairs = await Promise.all(decryptionPromises)
decryptedEnvVars = Object.fromEntries(decryptedPairs)
}
// Start logging session
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: '', // TODO: Get from workflow if needed
variables: decryptedEnvVars,
})
// Create serialized workflow
const serializer = new Serializer()
const serializedWorkflow = serializer.serializeWorkflow(
mergedStates,
edges,
loops || {},
parallels || {},
true // Enable validation during execution
)
// Create executor and execute
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: payload.input || {},
workflowVariables: {},
contextExtensions: {
executionId,
workspaceId: '', // TODO: Get from workflow if needed - see comment on line 120
},
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
const result = await executor.execute(workflowId)
// Handle streaming vs regular result
const executionResult =
'stream' in result && 'execution' in result ? result.execution : result
logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, {
success: executionResult.success,
executionTime: executionResult.metadata?.duration,
executionId,
})
// Update workflow run counts on success
if (executionResult.success) {
await updateWorkflowRunCounts(workflowId)
// Track execution in user stats
const statsUpdate =
triggerType === 'api'
? { totalApiCalls: sql`total_api_calls + 1` }
: triggerType === 'webhook'
? { totalWebhookTriggers: sql`total_webhook_triggers + 1` }
: triggerType === 'schedule'
? { totalScheduledExecutions: sql`total_scheduled_executions + 1` }
: { totalManualExecutions: sql`total_manual_executions + 1` }
await db
.update(userStats)
.set({
...statsUpdate,
lastActive: sql`now()`,
})
.where(eq(userStats.userId, payload.userId))
}
// Build trace spans and complete logging session (for both success and failure)
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: traceSpans as any,
})
return {
success: executionResult.success,
workflowId: payload.workflowId,
executionId,
output: executionResult.output,
executedAt: new Date().toISOString(),
metadata: payload.metadata,
}
} catch (error: any) {
logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, {
error: error.message,
stack: error.stack,
})
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Workflow execution failed',
stackTrace: error.stack,
},
})
throw error // Let Trigger.dev handle retries
}
},
run: async (payload: WorkflowExecutionPayload) => executeWorkflowJob(payload),
})

View File

@@ -106,6 +106,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
layout: 'full',
required: true,
placeholder: 'Enter new summary for the issue',
dependsOn: ['issueKey'],
condition: { field: 'operation', value: ['update', 'write'] },
},
{
@@ -114,6 +115,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
type: 'long-input',
layout: 'full',
placeholder: 'Enter new description for the issue',
dependsOn: ['issueKey'],
condition: { field: 'operation', value: ['update', 'write'] },
},
],

View File

@@ -0,0 +1,255 @@
import { MySQLIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { MySQLResponse } from '@/tools/mysql/types'
export const MySQLBlock: BlockConfig<MySQLResponse> = {
type: 'mysql',
name: 'MySQL',
description: 'Connect to MySQL database',
longDescription:
'Connect to any MySQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.',
docsLink: 'https://docs.sim.ai/tools/mysql',
category: 'tools',
bgColor: '#E0E0E0',
icon: MySQLIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Query (SELECT)', id: 'query' },
{ label: 'Insert Data', id: 'insert' },
{ label: 'Update Data', id: 'update' },
{ label: 'Delete Data', id: 'delete' },
{ label: 'Execute Raw SQL', id: 'execute' },
],
value: () => 'query',
},
{
id: 'host',
title: 'Host',
type: 'short-input',
layout: 'full',
placeholder: 'localhost or your.database.host',
required: true,
},
{
id: 'port',
title: 'Port',
type: 'short-input',
layout: 'full',
placeholder: '3306',
value: () => '3306',
required: true,
},
{
id: 'database',
title: 'Database Name',
type: 'short-input',
layout: 'full',
placeholder: 'your_database',
required: true,
},
{
id: 'username',
title: 'Username',
type: 'short-input',
layout: 'full',
placeholder: 'root',
required: true,
},
{
id: 'password',
title: 'Password',
type: 'short-input',
layout: 'full',
password: true,
placeholder: 'Your database password',
required: true,
},
{
id: 'ssl',
title: 'SSL Mode',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Disabled', id: 'disabled' },
{ label: 'Required', id: 'required' },
{ label: 'Preferred', id: 'preferred' },
],
value: () => 'preferred',
},
// Table field for insert/update/delete operations
{
id: 'table',
title: 'Table Name',
type: 'short-input',
layout: 'full',
placeholder: 'users',
condition: { field: 'operation', value: 'insert' },
required: true,
},
{
id: 'table',
title: 'Table Name',
type: 'short-input',
layout: 'full',
placeholder: 'users',
condition: { field: 'operation', value: 'update' },
required: true,
},
{
id: 'table',
title: 'Table Name',
type: 'short-input',
layout: 'full',
placeholder: 'users',
condition: { field: 'operation', value: 'delete' },
required: true,
},
// SQL Query field
{
id: 'query',
title: 'SQL Query',
type: 'code',
layout: 'full',
placeholder: 'SELECT * FROM users WHERE active = true',
condition: { field: 'operation', value: 'query' },
required: true,
},
{
id: 'query',
title: 'SQL Query',
type: 'code',
layout: 'full',
placeholder: 'SELECT * FROM table_name',
condition: { field: 'operation', value: 'execute' },
required: true,
},
// Data for insert operations
{
id: 'data',
title: 'Data (JSON)',
type: 'code',
layout: 'full',
placeholder: '{\n "name": "John Doe",\n "email": "john@example.com",\n "active": true\n}',
condition: { field: 'operation', value: 'insert' },
required: true,
},
// Set clause for updates
{
id: 'data',
title: 'Update Data (JSON)',
type: 'code',
layout: 'full',
placeholder: '{\n "name": "Jane Doe",\n "email": "jane@example.com"\n}',
condition: { field: 'operation', value: 'update' },
required: true,
},
// Where clause for update/delete
{
id: 'where',
title: 'WHERE Condition',
type: 'short-input',
layout: 'full',
placeholder: 'id = 1',
condition: { field: 'operation', value: 'update' },
required: true,
},
{
id: 'where',
title: 'WHERE Condition',
type: 'short-input',
layout: 'full',
placeholder: 'id = 1',
condition: { field: 'operation', value: 'delete' },
required: true,
},
],
tools: {
access: ['mysql_query', 'mysql_insert', 'mysql_update', 'mysql_delete', 'mysql_execute'],
config: {
tool: (params) => {
switch (params.operation) {
case 'query':
return 'mysql_query'
case 'insert':
return 'mysql_insert'
case 'update':
return 'mysql_update'
case 'delete':
return 'mysql_delete'
case 'execute':
return 'mysql_execute'
default:
throw new Error(`Invalid MySQL operation: ${params.operation}`)
}
},
params: (params) => {
const { operation, data, ...rest } = params
// Parse JSON data if it's a string
let parsedData
if (data && typeof data === 'string' && data.trim()) {
try {
parsedData = JSON.parse(data)
} catch (parseError) {
const errorMsg = parseError instanceof Error ? parseError.message : 'Unknown JSON error'
throw new Error(`Invalid JSON data format: ${errorMsg}. Please check your JSON syntax.`)
}
} else if (data && typeof data === 'object') {
parsedData = data
}
// Build connection config
const connectionConfig = {
host: rest.host,
port: typeof rest.port === 'string' ? Number.parseInt(rest.port, 10) : rest.port || 3306,
database: rest.database,
username: rest.username,
password: rest.password,
ssl: rest.ssl || 'preferred',
}
// Build params object
const result: any = { ...connectionConfig }
if (rest.table) result.table = rest.table
if (rest.query) result.query = rest.query
if (rest.where) result.where = rest.where
if (parsedData !== undefined) result.data = parsedData
return result
},
},
},
inputs: {
operation: { type: 'string', description: 'Database operation to perform' },
host: { type: 'string', description: 'Database host' },
port: { type: 'string', description: 'Database port' },
database: { type: 'string', description: 'Database name' },
username: { type: 'string', description: 'Database username' },
password: { type: 'string', description: 'Database password' },
ssl: { type: 'string', description: 'SSL mode' },
table: { type: 'string', description: 'Table name' },
query: { type: 'string', description: 'SQL query to execute' },
data: { type: 'json', description: 'Data for insert/update operations' },
where: { type: 'string', description: 'WHERE clause for update/delete' },
},
outputs: {
message: {
type: 'string',
description: 'Success or error message describing the operation outcome',
},
rows: {
type: 'array',
description: 'Array of rows returned from the query',
},
rowCount: {
type: 'number',
description: 'Number of rows affected by the operation',
},
},
}

View File

@@ -200,15 +200,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
params: (params) => {
const { credential, folderSelector, manualFolderId, mimeType, ...rest } = params
// Use folderSelector if provided, otherwise use manualFolderId
const effectiveFolderId = (folderSelector || manualFolderId || '').trim()
return {
...rest,
accessToken: credential,
folderId: effectiveFolderId,
// Pass both; tools will prioritize manualFolderId over folderSelector
folderSelector,
manualFolderId,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
...rest,
}
},
},

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