mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb6fffe38 | ||
|
|
ba2377f83b | ||
|
|
f502f984f3 | ||
|
|
74f371cc79 | ||
|
|
4fbec0a43f | ||
|
|
d248557042 | ||
|
|
8215a819e5 | ||
|
|
155f544ce8 | ||
|
|
22f949a41c | ||
|
|
f9aef6ae22 | ||
|
|
46b04a964d | ||
|
|
964b40de45 | ||
|
|
75aca00b6e | ||
|
|
d25084e05d | ||
|
|
445932c1c8 | ||
|
|
cc3f565d5e | ||
|
|
585f5e365b | ||
|
|
0977ed228f | ||
|
|
ed6b9c0c4a | ||
|
|
86bcdcf0d3 | ||
|
|
ac942416de | ||
|
|
195e0e8e3f | ||
|
|
1673ef98ac | ||
|
|
356b473dc3 |
@@ -123,8 +123,6 @@ Kontostand und Portfoliowert von Kalshi abrufen
|
||||
| --------- | ---- | ----------- |
|
||||
| `balance` | number | Kontostand in Cent |
|
||||
| `portfolioValue` | number | Portfoliowert in Cent |
|
||||
| `balanceDollars` | number | Kontostand in Dollar |
|
||||
| `portfolioValueDollars` | number | Portfoliowert in Dollar |
|
||||
|
||||
### `kalshi_get_positions`
|
||||
|
||||
|
||||
@@ -47,10 +47,11 @@ Daten aus einer Supabase-Tabelle abfragen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z. B. jdrkgepadsdopsntdlom\) |
|
||||
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z.B. jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Ja | Der Name der abzufragenden Supabase-Tabelle |
|
||||
| `schema` | string | Nein | Datenbankschema für die Abfrage \(Standard: public\). Verwenden Sie dies, um auf Tabellen in anderen Schemas zuzugreifen. |
|
||||
| `filter` | string | Nein | PostgREST-Filter \(z. B. "id=eq.123"\) |
|
||||
| `select` | string | Nein | Zurückzugebende Spalten \(durch Komma getrennt\). Standard ist * \(alle Spalten\) |
|
||||
| `filter` | string | Nein | PostgREST-Filter \(z.B. "id=eq.123"\) |
|
||||
| `orderBy` | string | Nein | Spalte zum Sortieren \(fügen Sie DESC für absteigende Sortierung hinzu\) |
|
||||
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Zeilen |
|
||||
| `apiKey` | string | Ja | Ihr Supabase Service Role Secret Key |
|
||||
@@ -91,10 +92,11 @@ Eine einzelne Zeile aus einer Supabase-Tabelle basierend auf Filterkriterien abr
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z. B. jdrkgepadsdopsntdlom\) |
|
||||
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z.B. jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Ja | Der Name der abzufragenden Supabase-Tabelle |
|
||||
| `schema` | string | Nein | Datenbankschema für die Abfrage \(Standard: public\). Verwenden Sie dies, um auf Tabellen in anderen Schemas zuzugreifen. |
|
||||
| `filter` | string | Ja | PostgREST-Filter zum Auffinden der spezifischen Zeile \(z. B. "id=eq.123"\) |
|
||||
| `select` | string | Nein | Zurückzugebende Spalten \(durch Komma getrennt\). Standard ist * \(alle Spalten\) |
|
||||
| `filter` | string | Ja | PostgREST-Filter zum Finden der spezifischen Zeile \(z.B. "id=eq.123"\) |
|
||||
| `apiKey` | string | Ja | Ihr Supabase Service Role Secret Key |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
@@ -126,8 +126,6 @@ Retrieve your account balance and portfolio value from Kalshi
|
||||
| --------- | ---- | ----------- |
|
||||
| `balance` | number | Account balance in cents |
|
||||
| `portfolioValue` | number | Portfolio value in cents |
|
||||
| `balanceDollars` | number | Account balance in dollars |
|
||||
| `portfolioValueDollars` | number | Portfolio value in dollars |
|
||||
|
||||
### `kalshi_get_positions`
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ Query data from a Supabase table
|
||||
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Yes | The name of the Supabase table to query |
|
||||
| `schema` | string | No | Database schema to query from \(default: public\). Use this to access tables in other schemas. |
|
||||
| `select` | string | No | Columns to return \(comma-separated\). Defaults to * \(all columns\) |
|
||||
| `filter` | string | No | PostgREST filter \(e.g., "id=eq.123"\) |
|
||||
| `orderBy` | string | No | Column to order by \(add DESC for descending\) |
|
||||
| `limit` | number | No | Maximum number of rows to return |
|
||||
@@ -97,6 +98,7 @@ Get a single row from a Supabase table based on filter criteria
|
||||
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Yes | The name of the Supabase table to query |
|
||||
| `schema` | string | No | Database schema to query from \(default: public\). Use this to access tables in other schemas. |
|
||||
| `select` | string | No | Columns to return \(comma-separated\). Defaults to * \(all columns\) |
|
||||
| `filter` | string | Yes | PostgREST filter to find the specific row \(e.g., "id=eq.123"\) |
|
||||
| `apiKey` | string | Yes | Your Supabase service role secret key |
|
||||
|
||||
|
||||
@@ -122,9 +122,7 @@ Recuperar el saldo de tu cuenta y el valor de la cartera desde Kalshi
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `balance` | number | Saldo de la cuenta en centavos |
|
||||
| `portfolioValue` | number | Valor de la cartera en centavos |
|
||||
| `balanceDollars` | number | Saldo de la cuenta en dólares |
|
||||
| `portfolioValueDollars` | number | Valor de la cartera en dólares |
|
||||
| `portfolioValue` | number | Valor del portafolio en centavos |
|
||||
|
||||
### `kalshi_get_positions`
|
||||
|
||||
|
||||
@@ -46,12 +46,13 @@ Consultar datos de una tabla de Supabase
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Sí | ID de tu proyecto Supabase \(p. ej., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Sí | Nombre de la tabla Supabase a consultar |
|
||||
| `schema` | string | No | Esquema de base de datos desde donde consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
|
||||
| `schema` | string | No | Esquema de base de datos desde el que consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
|
||||
| `select` | string | No | Columnas a devolver \(separadas por comas\). Predeterminado: * \(todas las columnas\) |
|
||||
| `filter` | string | No | Filtro PostgREST \(p. ej., "id=eq.123"\) |
|
||||
| `orderBy` | string | No | Columna para ordenar \(añade DESC para descendente\) |
|
||||
| `orderBy` | string | No | Columna por la que ordenar \(añade DESC para orden descendente\) |
|
||||
| `limit` | number | No | Número máximo de filas a devolver |
|
||||
| `apiKey` | string | Sí | Tu clave secreta de rol de servicio de Supabase |
|
||||
|
||||
@@ -90,10 +91,11 @@ Obtener una sola fila de una tabla de Supabase basada en criterios de filtro
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Sí | ID de tu proyecto Supabase \(p. ej., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Sí | Nombre de la tabla Supabase a consultar |
|
||||
| `schema` | string | No | Esquema de base de datos desde donde consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
|
||||
| `schema` | string | No | Esquema de base de datos desde el que consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
|
||||
| `select` | string | No | Columnas a devolver \(separadas por comas\). Predeterminado: * \(todas las columnas\) |
|
||||
| `filter` | string | Sí | Filtro PostgREST para encontrar la fila específica \(p. ej., "id=eq.123"\) |
|
||||
| `apiKey` | string | Sí | Tu clave secreta de rol de servicio de Supabase |
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ Récupérer le solde de votre compte et la valeur de votre portefeuille depuis K
|
||||
| --------- | ---- | ----------- |
|
||||
| `balance` | number | Solde du compte en centimes |
|
||||
| `portfolioValue` | number | Valeur du portefeuille en centimes |
|
||||
| `balanceDollars` | number | Solde du compte en dollars |
|
||||
| `portfolioValueDollars` | number | Valeur du portefeuille en dollars |
|
||||
|
||||
### `kalshi_get_positions`
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ Interroger des données d'une table Supabase
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `projectId` | string | Oui | L'ID de votre projet Supabase \(ex. : jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Oui | Le nom de la table Supabase à interroger |
|
||||
| `schema` | string | Non | Schéma de base de données à interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
|
||||
| `schema` | string | Non | Schéma de base de données à partir duquel interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
|
||||
| `select` | string | Non | Colonnes à retourner \(séparées par des virgules\). Par défaut * \(toutes les colonnes\) |
|
||||
| `filter` | string | Non | Filtre PostgREST \(ex. : "id=eq.123"\) |
|
||||
| `orderBy` | string | Non | Colonne pour le tri \(ajoutez DESC pour l'ordre décroissant\) |
|
||||
| `limit` | number | Non | Nombre maximum de lignes à retourner |
|
||||
@@ -93,7 +94,8 @@ Obtenir une seule ligne d'une table Supabase selon des critères de filtrage
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `projectId` | string | Oui | L'ID de votre projet Supabase \(ex. : jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Oui | Le nom de la table Supabase à interroger |
|
||||
| `schema` | string | Non | Schéma de base de données à interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
|
||||
| `schema` | string | Non | Schéma de base de données à partir duquel interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
|
||||
| `select` | string | Non | Colonnes à retourner \(séparées par des virgules\). Par défaut * \(toutes les colonnes\) |
|
||||
| `filter` | string | Oui | Filtre PostgREST pour trouver la ligne spécifique \(ex. : "id=eq.123"\) |
|
||||
| `apiKey` | string | Oui | Votre clé secrète de rôle de service Supabase |
|
||||
|
||||
|
||||
@@ -121,10 +121,8 @@ Kalshiからアカウント残高とポートフォリオ価値を取得
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `balance` | number | セント単位のアカウント残高 |
|
||||
| `portfolioValue` | number | セント単位のポートフォリオ価値 |
|
||||
| `balanceDollars` | number | ドル単位のアカウント残高 |
|
||||
| `portfolioValueDollars` | number | ドル単位のポートフォリオ価値 |
|
||||
| `balance` | number | アカウント残高(セント単位) |
|
||||
| `portfolioValue` | number | ポートフォリオ価値(セント単位) |
|
||||
|
||||
### `kalshi_get_positions`
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ Supabaseテーブルからデータを照会する
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | はい | あなたのSupabaseプロジェクトID(例:jdrkgepadsdopsntdlom) |
|
||||
| `table` | string | はい | クエリするSupabaseテーブルの名前 |
|
||||
| `schema` | string | いいえ | クエリするデータベーススキーマ(デフォルト:public)。他のスキーマのテーブルにアクセスする場合に使用します。 |
|
||||
| `schema` | string | いいえ | クエリ元のデータベーススキーマ(デフォルト:public)。他のスキーマのテーブルにアクセスする場合に使用します。 |
|
||||
| `select` | string | いいえ | 返す列(カンマ区切り)。デフォルトは*(すべての列) |
|
||||
| `filter` | string | いいえ | PostgRESTフィルター(例:"id=eq.123") |
|
||||
| `orderBy` | string | いいえ | 並べ替える列(降順の場合はDESCを追加) |
|
||||
| `limit` | number | いいえ | 返す最大行数 |
|
||||
@@ -93,7 +94,8 @@ Supabaseテーブルにデータを挿入する
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | はい | あなたのSupabaseプロジェクトID(例:jdrkgepadsdopsntdlom) |
|
||||
| `table` | string | はい | クエリするSupabaseテーブルの名前 |
|
||||
| `schema` | string | いいえ | クエリするデータベーススキーマ(デフォルト:public)。他のスキーマのテーブルにアクセスする場合に使用します。 |
|
||||
| `schema` | string | いいえ | クエリ元のデータベーススキーマ(デフォルト:public)。他のスキーマのテーブルにアクセスする場合に使用します。 |
|
||||
| `select` | string | いいえ | 返す列(カンマ区切り)。デフォルトは*(すべての列) |
|
||||
| `filter` | string | はい | 特定の行を見つけるためのPostgRESTフィルター(例:"id=eq.123") |
|
||||
| `apiKey` | string | はい | あなたのSupabaseサービスロールシークレットキー |
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
| --------- | ---- | ----------- |
|
||||
| `balance` | number | 账户余额(以分为单位) |
|
||||
| `portfolioValue` | number | 投资组合价值(以分为单位) |
|
||||
| `balanceDollars` | number | 账户余额(以美元为单位) |
|
||||
| `portfolioValueDollars` | number | 投资组合价值(以美元为单位) |
|
||||
|
||||
### `kalshi_get_positions`
|
||||
|
||||
|
||||
@@ -50,8 +50,9 @@ Sim 的 Supabase 集成使您能够轻松地将代理工作流连接到您的 Su
|
||||
| `projectId` | string | 是 | 您的 Supabase 项目 ID \(例如:jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | 是 | 要查询的 Supabase 表名 |
|
||||
| `schema` | string | 否 | 要查询的数据库 schema \(默认:public\)。用于访问其他 schema 下的表。|
|
||||
| `select` | string | 否 | 要返回的列(逗号分隔)。默认为 *(所有列)|
|
||||
| `filter` | string | 否 | PostgREST 过滤条件 \(例如:"id=eq.123"\) |
|
||||
| `orderBy` | string | 否 | 排序的列名 \(添加 DESC 表示降序\) |
|
||||
| `orderBy` | string | 否 | 排序的列(添加 DESC 表示降序)|
|
||||
| `limit` | number | 否 | 返回的最大行数 |
|
||||
| `apiKey` | string | 是 | 您的 Supabase 服务角色密钥 |
|
||||
|
||||
@@ -94,7 +95,8 @@ Sim 的 Supabase 集成使您能够轻松地将代理工作流连接到您的 Su
|
||||
| `projectId` | string | 是 | 您的 Supabase 项目 ID \(例如:jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | 是 | 要查询的 Supabase 表名 |
|
||||
| `schema` | string | 否 | 要查询的数据库 schema \(默认:public\)。用于访问其他 schema 下的表。|
|
||||
| `filter` | string | 是 | 用于查找特定行的 PostgREST 过滤条件 \(例如:"id=eq.123"\) |
|
||||
| `select` | string | 否 | 要返回的列(逗号分隔)。默认为 *(所有列)|
|
||||
| `filter` | string | 是 | PostgREST 过滤条件,用于查找特定行 \(例如:"id=eq.123"\) |
|
||||
| `apiKey` | string | 是 | 您的 Supabase 服务角色密钥 |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -700,7 +700,7 @@ checksums:
|
||||
content/11: 04bd9805ef6a50af8469463c34486dbf
|
||||
content/12: a3671dd7ba76a87dc75464d9bf9b7b4b
|
||||
content/13: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/14: 80578981b8b3a1cf579e52ff05e7468d
|
||||
content/14: 5102b3705883f9e0c5440aeabafd1d24
|
||||
content/15: bcadfc362b69078beee0088e5936c98b
|
||||
content/16: 09ed43219d02501c829594dbf4128959
|
||||
content/17: 88ae2285d728c80937e1df8194d92c60
|
||||
@@ -712,7 +712,7 @@ checksums:
|
||||
content/23: 7d96d99e45880195ccbd34bddaac6319
|
||||
content/24: 75d05f96dff406db06b338d9ab8d0bd7
|
||||
content/25: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/26: cfd801fa517b4bcfa5fa034b2c4e908a
|
||||
content/26: 38373ac018fd7db3a20ba5308beac81e
|
||||
content/27: bcadfc362b69078beee0088e5936c98b
|
||||
content/28: a0284632eb0a15e66f69479ec477c5b1
|
||||
content/29: b1e60734e590a8ad894a96581a253bf4
|
||||
@@ -48276,7 +48276,7 @@ checksums:
|
||||
content/35: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/36: bddd30707802c07aac61620721bfaf16
|
||||
content/37: bcadfc362b69078beee0088e5936c98b
|
||||
content/38: fa2c581e6fb204f5ddbd0ffcbf0f7123
|
||||
content/38: 4619dad6a45478396332397f1e53db85
|
||||
content/39: 65de097e276f762b71d59fa7f9b0a207
|
||||
content/40: 013f52c249b5919fdb6d96700b25f379
|
||||
content/41: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
@layer base {
|
||||
:root,
|
||||
.light {
|
||||
--bg: #fdfdfd; /* main canvas - neutral near-white */
|
||||
--surface-1: #fcfcfc; /* sidebar, panels */
|
||||
--bg: #fefefe; /* main canvas - neutral near-white */
|
||||
--surface-1: #fefefe; /* sidebar, panels */
|
||||
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
|
||||
--surface-3: #f7f7f7; /* popovers, headers */
|
||||
--surface-4: #f5f5f5; /* buttons base */
|
||||
@@ -70,6 +70,7 @@
|
||||
--text-muted: #737373;
|
||||
--text-subtle: #8c8c8c;
|
||||
--text-inverse: #ffffff;
|
||||
--text-muted-inverse: #a0a0a0;
|
||||
--text-error: #ef4444;
|
||||
|
||||
/* Borders / dividers */
|
||||
@@ -186,6 +187,7 @@
|
||||
--text-muted: #787878;
|
||||
--text-subtle: #7d7d7d;
|
||||
--text-inverse: #1b1b1b;
|
||||
--text-muted-inverse: #b3b3b3;
|
||||
--text-error: #ef4444;
|
||||
|
||||
/* --border-strong: #303030; */
|
||||
@@ -331,38 +333,38 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--surface-1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--surface-7);
|
||||
background-color: #c0c0c0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--surface-7);
|
||||
background-color: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Dark Mode Global Scrollbar */
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: var(--surface-4);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--surface-7);
|
||||
background-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--surface-7);
|
||||
background-color: #6a6a6a;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface-7) var(--surface-1);
|
||||
scrollbar-color: #c0c0c0 transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: var(--surface-7) var(--surface-4);
|
||||
scrollbar-color: #5a5a5a transparent;
|
||||
}
|
||||
|
||||
.copilot-scrollable {
|
||||
|
||||
@@ -136,16 +136,29 @@ vi.mock('@sim/db', () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => Promise.resolve(),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (records: any) => {
|
||||
dbOps.order.push('insert')
|
||||
dbOps.insertRecords.push(records)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
transaction: vi.fn(async (fn: any) => {
|
||||
await fn({
|
||||
insert: (table: any) => ({
|
||||
delete: () => ({
|
||||
where: () => Promise.resolve(),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (records: any) => {
|
||||
dbOps.order.push('insert')
|
||||
dbOps.insertRecords.push(records)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
update: (table: any) => ({
|
||||
update: () => ({
|
||||
set: (payload: any) => ({
|
||||
where: () => {
|
||||
dbOps.updatePayloads.push(payload)
|
||||
|
||||
@@ -21,14 +21,15 @@ export async function POST(
|
||||
) {
|
||||
const { workflowId, executionId, contextId } = await params
|
||||
|
||||
// Allow resume from dashboard without requiring deployment
|
||||
const access = await validateWorkflowAccess(request, workflowId, false)
|
||||
if (access.error) {
|
||||
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
|
||||
}
|
||||
|
||||
const workflow = access.workflow!
|
||||
const workflow = access.workflow
|
||||
|
||||
let payload: any = {}
|
||||
let payload: Record<string, unknown> = {}
|
||||
try {
|
||||
payload = await request.json()
|
||||
} catch {
|
||||
@@ -148,6 +149,7 @@ export async function GET(
|
||||
) {
|
||||
const { workflowId, executionId, contextId } = await params
|
||||
|
||||
// Allow access without API key for browser-based UI (same as parent execution endpoint)
|
||||
const access = await validateWorkflowAccess(request, workflowId, false)
|
||||
if (access.error) {
|
||||
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
checkWebhookPreprocessing,
|
||||
findWebhookAndWorkflow,
|
||||
handleProviderChallenges,
|
||||
handleProviderReachabilityTest,
|
||||
parseWebhookBody,
|
||||
queueWebhookExecution,
|
||||
verifyProviderAuth,
|
||||
@@ -123,6 +124,11 @@ export async function POST(
|
||||
return authError
|
||||
}
|
||||
|
||||
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
|
||||
if (reachabilityResponse) {
|
||||
return reachabilityResponse
|
||||
}
|
||||
|
||||
let preprocessError: NextResponse | null = null
|
||||
try {
|
||||
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
|
||||
|
||||
@@ -16,7 +16,7 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
|
||||
<span className='truncate font-medium text-xs'>{href}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface ChunkContextMenuProps {
|
||||
isOpen: boolean
|
||||
@@ -39,11 +45,24 @@ interface ChunkContextMenuProps {
|
||||
* Whether add chunk is disabled
|
||||
*/
|
||||
disableAddChunk?: boolean
|
||||
/**
|
||||
* Number of selected chunks (for batch operations)
|
||||
*/
|
||||
selectedCount?: number
|
||||
/**
|
||||
* Number of enabled chunks in selection
|
||||
*/
|
||||
enabledCount?: number
|
||||
/**
|
||||
* Number of disabled chunks in selection
|
||||
*/
|
||||
disabledCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for chunks table.
|
||||
* Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space.
|
||||
* Supports batch operations when multiple chunks are selected.
|
||||
*/
|
||||
export function ChunkContextMenu({
|
||||
isOpen,
|
||||
@@ -61,7 +80,20 @@ export function ChunkContextMenu({
|
||||
disableToggleEnabled = false,
|
||||
disableDelete = false,
|
||||
disableAddChunk = false,
|
||||
selectedCount = 1,
|
||||
enabledCount = 0,
|
||||
disabledCount = 0,
|
||||
}: ChunkContextMenuProps) {
|
||||
const isMultiSelect = selectedCount > 1
|
||||
|
||||
const getToggleLabel = () => {
|
||||
if (isMultiSelect) {
|
||||
if (disabledCount > 0) return 'Enable'
|
||||
return 'Disable'
|
||||
}
|
||||
return isChunkEnabled ? 'Disable' : 'Enable'
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
@@ -76,7 +108,8 @@ export function ChunkContextMenu({
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{hasChunk ? (
|
||||
<>
|
||||
{onOpenInNewTab && (
|
||||
{/* Navigation */}
|
||||
{!isMultiSelect && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenInNewTab()
|
||||
@@ -86,7 +119,10 @@ export function ChunkContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
{!isMultiSelect && onOpenInNewTab && <PopoverDivider />}
|
||||
|
||||
{/* Edit and copy actions */}
|
||||
{!isMultiSelect && onEdit && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onEdit()
|
||||
@@ -96,7 +132,7 @@ export function ChunkContextMenu({
|
||||
Edit
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onCopyContent && (
|
||||
{!isMultiSelect && onCopyContent && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopyContent()
|
||||
@@ -106,6 +142,9 @@ export function ChunkContextMenu({
|
||||
Copy content
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!isMultiSelect && (onEdit || onCopyContent) && <PopoverDivider />}
|
||||
|
||||
{/* State toggle */}
|
||||
{onToggleEnabled && (
|
||||
<PopoverItem
|
||||
disabled={disableToggleEnabled}
|
||||
@@ -114,9 +153,16 @@ export function ChunkContextMenu({
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{isChunkEnabled ? 'Disable' : 'Enable'}
|
||||
{getToggleLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
{onDelete &&
|
||||
((!isMultiSelect && onOpenInNewTab) ||
|
||||
(!isMultiSelect && onEdit) ||
|
||||
(!isMultiSelect && onCopyContent) ||
|
||||
onToggleEnabled) && <PopoverDivider />}
|
||||
{onDelete && (
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -107,14 +108,31 @@ interface DocumentProps {
|
||||
documentName?: string
|
||||
}
|
||||
|
||||
function getStatusBadgeStyles(enabled: boolean) {
|
||||
return enabled
|
||||
? 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}
|
||||
|
||||
function truncateContent(content: string, maxLength = 150): string {
|
||||
function truncateContent(content: string, maxLength = 150, searchQuery = ''): string {
|
||||
if (content.length <= maxLength) return content
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const searchTerms = searchQuery
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((term) => term.length > 0)
|
||||
.map((term) => term.toLowerCase())
|
||||
|
||||
for (const term of searchTerms) {
|
||||
const matchIndex = content.toLowerCase().indexOf(term)
|
||||
if (matchIndex !== -1) {
|
||||
const contextBefore = 30
|
||||
const start = Math.max(0, matchIndex - contextBefore)
|
||||
const end = Math.min(content.length, start + maxLength)
|
||||
|
||||
let result = content.substring(start, end)
|
||||
if (start > 0) result = `...${result}`
|
||||
if (end < content.length) result = `${result}...`
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${content.substring(0, maxLength)}...`
|
||||
}
|
||||
|
||||
@@ -655,13 +673,21 @@ export function Document({
|
||||
|
||||
/**
|
||||
* Handle right-click on a chunk row
|
||||
* If right-clicking on an unselected chunk, select only that chunk
|
||||
* If right-clicking on a selected chunk with multiple selections, keep all selections
|
||||
*/
|
||||
const handleChunkContextMenu = useCallback(
|
||||
(e: React.MouseEvent, chunk: ChunkData) => {
|
||||
const isCurrentlySelected = selectedChunks.has(chunk.id)
|
||||
|
||||
if (!isCurrentlySelected) {
|
||||
setSelectedChunks(new Set([chunk.id]))
|
||||
}
|
||||
|
||||
setContextMenuChunk(chunk)
|
||||
baseHandleContextMenu(e)
|
||||
},
|
||||
[baseHandleContextMenu]
|
||||
[selectedChunks, baseHandleContextMenu]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -946,106 +972,114 @@ export function Document({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
displayChunks.map((chunk: ChunkData) => (
|
||||
<TableRow
|
||||
key={chunk.id}
|
||||
className='cursor-pointer hover:bg-[var(--surface-2)]'
|
||||
onClick={() => handleChunkClick(chunk)}
|
||||
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
|
||||
>
|
||||
<TableCell
|
||||
className='w-[52px] py-[8px]'
|
||||
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
|
||||
displayChunks.map((chunk: ChunkData) => {
|
||||
const isSelected = selectedChunks.has(chunk.id)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={chunk.id}
|
||||
className={`${
|
||||
isSelected
|
||||
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
|
||||
} cursor-pointer`}
|
||||
onClick={() => handleChunkClick(chunk)}
|
||||
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectedChunks.has(chunk.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectChunk(chunk.id, checked as boolean)
|
||||
}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label={`Select chunk ${chunk.chunkIndex}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
|
||||
{chunk.chunkIndex}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={chunk.content}
|
||||
<TableCell
|
||||
className='w-[52px] py-[8px]'
|
||||
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
|
||||
>
|
||||
<SearchHighlight
|
||||
text={truncateContent(chunk.content)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
{chunk.tokenCount > 1000
|
||||
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
|
||||
: chunk.tokenCount}
|
||||
</TableCell>
|
||||
<TableCell className='w-[12%] px-[12px] py-[8px]'>
|
||||
<div className={getStatusBadgeStyles(chunk.enabled)}>
|
||||
{chunk.enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleEnabled(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
|
||||
>
|
||||
{chunk.enabled ? (
|
||||
<Circle className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<CircleOff className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to modify chunks'
|
||||
: chunk.enabled
|
||||
? 'Disable Chunk'
|
||||
: 'Enable Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteChunk(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to delete chunks'
|
||||
: 'Delete Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
<div className='flex items-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectedChunks.has(chunk.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectChunk(chunk.id, checked as boolean)
|
||||
}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label={`Select chunk ${chunk.chunkIndex}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
|
||||
{chunk.chunkIndex}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={chunk.content}
|
||||
>
|
||||
<SearchHighlight
|
||||
text={truncateContent(chunk.content, 150, searchQuery)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
{chunk.tokenCount > 1000
|
||||
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
|
||||
: chunk.tokenCount.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className='w-[12%] px-[12px] py-[8px]'>
|
||||
<Badge variant={chunk.enabled ? 'green' : 'gray'} size='sm'>
|
||||
{chunk.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleEnabled(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
|
||||
>
|
||||
{chunk.enabled ? (
|
||||
<Circle className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<CircleOff className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to modify chunks'
|
||||
: chunk.enabled
|
||||
? 'Disable Chunk'
|
||||
: 'Enable Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteChunk(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to delete chunks'
|
||||
: 'Delete Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -1206,8 +1240,11 @@ export function Document({
|
||||
onClose={handleContextMenuClose}
|
||||
hasChunk={contextMenuChunk !== null}
|
||||
isChunkEnabled={contextMenuChunk?.enabled ?? true}
|
||||
selectedCount={selectedChunks.size}
|
||||
enabledCount={enabledCount}
|
||||
disabledCount={disabledCount}
|
||||
onOpenInNewTab={
|
||||
contextMenuChunk
|
||||
contextMenuChunk && selectedChunks.size === 1
|
||||
? () => {
|
||||
const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}`
|
||||
window.open(url, '_blank')
|
||||
@@ -1215,7 +1252,7 @@ export function Document({
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
contextMenuChunk
|
||||
contextMenuChunk && selectedChunks.size === 1
|
||||
? () => {
|
||||
setSelectedChunk(contextMenuChunk)
|
||||
setIsModalOpen(true)
|
||||
@@ -1223,7 +1260,7 @@ export function Document({
|
||||
: undefined
|
||||
}
|
||||
onCopyContent={
|
||||
contextMenuChunk
|
||||
contextMenuChunk && selectedChunks.size === 1
|
||||
? () => {
|
||||
navigator.clipboard.writeText(contextMenuChunk.content)
|
||||
}
|
||||
@@ -1231,12 +1268,22 @@ export function Document({
|
||||
}
|
||||
onToggleEnabled={
|
||||
contextMenuChunk && userPermissions.canEdit
|
||||
? () => handleToggleEnabled(contextMenuChunk.id)
|
||||
? selectedChunks.size > 1
|
||||
? () => {
|
||||
if (disabledCount > 0) {
|
||||
handleBulkEnable()
|
||||
} else {
|
||||
handleBulkDisable()
|
||||
}
|
||||
}
|
||||
: () => handleToggleEnabled(contextMenuChunk.id)
|
||||
: undefined
|
||||
}
|
||||
onDelete={
|
||||
contextMenuChunk && userPermissions.canEdit
|
||||
? () => handleDeleteChunk(contextMenuChunk.id)
|
||||
? selectedChunks.size > 1
|
||||
? handleBulkDelete
|
||||
: () => handleDeleteChunk(contextMenuChunk.id)
|
||||
: undefined
|
||||
}
|
||||
onAddChunk={
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -47,10 +48,12 @@ import {
|
||||
AddDocumentsModal,
|
||||
BaseTagsModal,
|
||||
DocumentContextMenu,
|
||||
RenameDocumentModal,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import {
|
||||
useKnowledgeBase,
|
||||
useKnowledgeBaseDocuments,
|
||||
@@ -404,6 +407,7 @@ export function KnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseName: passedKnowledgeBaseName,
|
||||
}: KnowledgeBaseProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||
@@ -432,6 +436,8 @@ export function KnowledgeBase({
|
||||
const [sortBy, setSortBy] = useState<DocumentSortField>('uploadedAt')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -447,6 +453,8 @@ export function KnowledgeBase({
|
||||
error: knowledgeBaseError,
|
||||
refresh: refreshKnowledgeBase,
|
||||
} = useKnowledgeBase(id)
|
||||
const [hasProcessingDocuments, setHasProcessingDocuments] = useState(false)
|
||||
|
||||
const {
|
||||
documents,
|
||||
pagination,
|
||||
@@ -462,6 +470,7 @@ export function KnowledgeBase({
|
||||
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
refetchInterval: hasProcessingDocuments && !isDeleting ? 3000 : false,
|
||||
})
|
||||
|
||||
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
|
||||
@@ -528,25 +537,15 @@ export function KnowledgeBase({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const hasProcessingDocuments = documents.some(
|
||||
const processing = documents.some(
|
||||
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
|
||||
)
|
||||
setHasProcessingDocuments(processing)
|
||||
|
||||
if (!hasProcessingDocuments) return
|
||||
|
||||
const refreshInterval = setInterval(async () => {
|
||||
try {
|
||||
if (!isDeleting) {
|
||||
await checkForDeadProcesses()
|
||||
await refreshDocuments()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error refreshing documents:', error)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(refreshInterval)
|
||||
}, [documents, refreshDocuments, isDeleting])
|
||||
if (processing) {
|
||||
checkForDeadProcesses()
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
/**
|
||||
* Checks for documents with stale processing states and marks them as failed
|
||||
@@ -666,25 +665,6 @@ export function KnowledgeBase({
|
||||
|
||||
await refreshDocuments()
|
||||
|
||||
let refreshAttempts = 0
|
||||
const maxRefreshAttempts = 3
|
||||
const refreshInterval = setInterval(async () => {
|
||||
try {
|
||||
refreshAttempts++
|
||||
await refreshDocuments()
|
||||
if (refreshAttempts >= maxRefreshAttempts) {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error refreshing documents after retry:', error)
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(refreshInterval)
|
||||
}, 4000)
|
||||
|
||||
logger.info(`Document retry initiated successfully for: ${docId}`)
|
||||
} catch (err) {
|
||||
logger.error('Error retrying document:', err)
|
||||
@@ -699,6 +679,60 @@ export function KnowledgeBase({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the rename document modal
|
||||
*/
|
||||
const handleRenameDocument = (doc: DocumentData) => {
|
||||
setDocumentToRename(doc)
|
||||
setShowRenameModal(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the renamed document
|
||||
*/
|
||||
const handleSaveRename = async (documentId: string, newName: string) => {
|
||||
const currentDoc = documents.find((doc) => doc.id === documentId)
|
||||
const previousName = currentDoc?.filename
|
||||
|
||||
updateDocument(documentId, { filename: newName })
|
||||
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
|
||||
previous ? { ...previous, filename: newName } : previous
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filename: newName }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to rename document')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to rename document')
|
||||
}
|
||||
|
||||
logger.info(`Document renamed: ${documentId}`)
|
||||
} catch (err) {
|
||||
if (previousName !== undefined) {
|
||||
updateDocument(documentId, { filename: previousName })
|
||||
queryClient.setQueryData<DocumentData>(
|
||||
knowledgeKeys.document(id, documentId),
|
||||
(previous) => (previous ? { ...previous, filename: previousName } : previous)
|
||||
)
|
||||
}
|
||||
logger.error('Error renaming document:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the delete document confirmation modal
|
||||
*/
|
||||
@@ -968,13 +1002,21 @@ export function KnowledgeBase({
|
||||
|
||||
/**
|
||||
* Handle right-click on a document row
|
||||
* If right-clicking on an unselected document, select only that document
|
||||
* If right-clicking on a selected document with multiple selections, keep all selections
|
||||
*/
|
||||
const handleDocumentContextMenu = useCallback(
|
||||
(e: React.MouseEvent, doc: DocumentData) => {
|
||||
const isCurrentlySelected = selectedDocuments.has(doc.id)
|
||||
|
||||
if (!isCurrentlySelected) {
|
||||
setSelectedDocuments(new Set([doc.id]))
|
||||
}
|
||||
|
||||
setContextMenuDocument(doc)
|
||||
baseHandleContextMenu(e)
|
||||
},
|
||||
[baseHandleContextMenu]
|
||||
[selectedDocuments, baseHandleContextMenu]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1211,7 +1253,9 @@ export function KnowledgeBase({
|
||||
<TableRow
|
||||
key={doc.id}
|
||||
className={`${
|
||||
isSelected ? 'bg-[var(--surface-2)]' : 'hover:bg-[var(--surface-2)]'
|
||||
isSelected
|
||||
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
|
||||
} ${doc.processingStatus === 'completed' ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
onClick={() => {
|
||||
if (doc.processingStatus === 'completed') {
|
||||
@@ -1558,6 +1602,17 @@ export function KnowledgeBase({
|
||||
chunkingConfig={knowledgeBase?.chunkingConfig}
|
||||
/>
|
||||
|
||||
{/* Rename Document Modal */}
|
||||
{documentToRename && (
|
||||
<RenameDocumentModal
|
||||
open={showRenameModal}
|
||||
onOpenChange={setShowRenameModal}
|
||||
documentId={documentToRename.id}
|
||||
initialName={documentToRename.filename}
|
||||
onSave={handleSaveRename}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionBar
|
||||
selectedCount={selectedDocuments.size}
|
||||
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
|
||||
@@ -1580,8 +1635,11 @@ export function KnowledgeBase({
|
||||
? getDocumentTags(contextMenuDocument, tagDefinitions).length > 0
|
||||
: false
|
||||
}
|
||||
selectedCount={selectedDocuments.size}
|
||||
enabledCount={enabledCount}
|
||||
disabledCount={disabledCount}
|
||||
onOpenInNewTab={
|
||||
contextMenuDocument
|
||||
contextMenuDocument && selectedDocuments.size === 1
|
||||
? () => {
|
||||
const urlParams = new URLSearchParams({
|
||||
kbName: knowledgeBaseName,
|
||||
@@ -1594,13 +1652,26 @@ export function KnowledgeBase({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRename={
|
||||
contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit
|
||||
? () => handleRenameDocument(contextMenuDocument)
|
||||
: undefined
|
||||
}
|
||||
onToggleEnabled={
|
||||
contextMenuDocument && userPermissions.canEdit
|
||||
? () => handleToggleEnabled(contextMenuDocument.id)
|
||||
? selectedDocuments.size > 1
|
||||
? () => {
|
||||
if (disabledCount > 0) {
|
||||
handleBulkEnable()
|
||||
} else {
|
||||
handleBulkDisable()
|
||||
}
|
||||
}
|
||||
: () => handleToggleEnabled(contextMenuDocument.id)
|
||||
: undefined
|
||||
}
|
||||
onViewTags={
|
||||
contextMenuDocument
|
||||
contextMenuDocument && selectedDocuments.size === 1
|
||||
? () => {
|
||||
const urlParams = new URLSearchParams({
|
||||
kbName: knowledgeBaseName,
|
||||
@@ -1614,7 +1685,9 @@ export function KnowledgeBase({
|
||||
}
|
||||
onDelete={
|
||||
contextMenuDocument && userPermissions.canEdit
|
||||
? () => handleDeleteDocument(contextMenuDocument.id)
|
||||
? selectedDocuments.size > 1
|
||||
? handleBulkDelete
|
||||
: () => handleDeleteDocument(contextMenuDocument.id)
|
||||
: undefined
|
||||
}
|
||||
onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface DocumentContextMenuProps {
|
||||
isOpen: boolean
|
||||
@@ -11,6 +17,7 @@ interface DocumentContextMenuProps {
|
||||
* Document-specific actions (shown when right-clicking on a document)
|
||||
*/
|
||||
onOpenInNewTab?: () => void
|
||||
onRename?: () => void
|
||||
onToggleEnabled?: () => void
|
||||
onViewTags?: () => void
|
||||
onDelete?: () => void
|
||||
@@ -42,11 +49,24 @@ interface DocumentContextMenuProps {
|
||||
* Whether add document is disabled
|
||||
*/
|
||||
disableAddDocument?: boolean
|
||||
/**
|
||||
* Number of selected documents (for batch operations)
|
||||
*/
|
||||
selectedCount?: number
|
||||
/**
|
||||
* Number of enabled documents in selection
|
||||
*/
|
||||
enabledCount?: number
|
||||
/**
|
||||
* Number of disabled documents in selection
|
||||
*/
|
||||
disabledCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for documents table.
|
||||
* Shows document actions when right-clicking a row, or "Add Document" when right-clicking empty space.
|
||||
* Supports batch operations when multiple documents are selected.
|
||||
*/
|
||||
export function DocumentContextMenu({
|
||||
isOpen,
|
||||
@@ -54,6 +74,7 @@ export function DocumentContextMenu({
|
||||
menuRef,
|
||||
onClose,
|
||||
onOpenInNewTab,
|
||||
onRename,
|
||||
onToggleEnabled,
|
||||
onViewTags,
|
||||
onDelete,
|
||||
@@ -64,7 +85,20 @@ export function DocumentContextMenu({
|
||||
disableToggleEnabled = false,
|
||||
disableDelete = false,
|
||||
disableAddDocument = false,
|
||||
selectedCount = 1,
|
||||
enabledCount = 0,
|
||||
disabledCount = 0,
|
||||
}: DocumentContextMenuProps) {
|
||||
const isMultiSelect = selectedCount > 1
|
||||
|
||||
const getToggleLabel = () => {
|
||||
if (isMultiSelect) {
|
||||
if (disabledCount > 0) return 'Enable'
|
||||
return 'Disable'
|
||||
}
|
||||
return isDocumentEnabled ? 'Disable' : 'Enable'
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
@@ -79,7 +113,8 @@ export function DocumentContextMenu({
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{hasDocument ? (
|
||||
<>
|
||||
{onOpenInNewTab && (
|
||||
{/* Navigation */}
|
||||
{!isMultiSelect && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenInNewTab()
|
||||
@@ -89,7 +124,20 @@ export function DocumentContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{hasTags && onViewTags && (
|
||||
{!isMultiSelect && onOpenInNewTab && <PopoverDivider />}
|
||||
|
||||
{/* Edit and view actions */}
|
||||
{!isMultiSelect && onRename && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onRename()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!isMultiSelect && hasTags && onViewTags && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewTags()
|
||||
@@ -99,6 +147,9 @@ export function DocumentContextMenu({
|
||||
View tags
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!isMultiSelect && (onRename || (hasTags && onViewTags)) && <PopoverDivider />}
|
||||
|
||||
{/* State toggle */}
|
||||
{onToggleEnabled && (
|
||||
<PopoverItem
|
||||
disabled={disableToggleEnabled}
|
||||
@@ -107,9 +158,16 @@ export function DocumentContextMenu({
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{isDocumentEnabled ? 'Disable' : 'Enable'}
|
||||
{getToggleLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
{onDelete &&
|
||||
((!isMultiSelect && onOpenInNewTab) ||
|
||||
(!isMultiSelect && onRename) ||
|
||||
(!isMultiSelect && hasTags && onViewTags) ||
|
||||
onToggleEnabled) && <PopoverDivider />}
|
||||
{onDelete && (
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
|
||||
@@ -2,3 +2,4 @@ export { ActionBar } from './action-bar/action-bar'
|
||||
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
|
||||
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
|
||||
export { DocumentContextMenu } from './document-context-menu'
|
||||
export { RenameDocumentModal } from './rename-document-modal'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { RenameDocumentModal } from './rename-document-modal'
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const logger = createLogger('RenameDocumentModal')
|
||||
|
||||
interface RenameDocumentModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
documentId: string
|
||||
initialName: string
|
||||
onSave: (documentId: string, newName: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for renaming a document.
|
||||
* Only changes the display name, not the underlying storage key.
|
||||
*/
|
||||
export function RenameDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentId,
|
||||
initialName,
|
||||
onSave,
|
||||
}: RenameDocumentModalProps) {
|
||||
const [name, setName] = useState(initialName)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, initialName])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const trimmedName = name.trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
setError('Name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedName === initialName) {
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSave(documentId, trimmedName)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Error renaming document:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename document')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename Document</ModalHeader>
|
||||
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='document-name'>Name</Label>
|
||||
<Input
|
||||
id='document-name'
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder='Enter document name'
|
||||
className={cn(error && 'border-[var(--text-error)]')}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{error ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex flex-shrink-0 gap-[8px]'>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => onOpenChange(false)}
|
||||
type='button'
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
|
||||
{isSubmitting ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface KnowledgeBaseContextMenuProps {
|
||||
/**
|
||||
@@ -104,6 +110,7 @@ export function KnowledgeBaseContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Navigation */}
|
||||
{showOpenInNewTab && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
@@ -114,6 +121,9 @@ export function KnowledgeBaseContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
|
||||
|
||||
{/* View and copy actions */}
|
||||
{showViewTags && onViewTags && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
@@ -134,6 +144,9 @@ export function KnowledgeBaseContextMenu({
|
||||
Copy ID
|
||||
</PopoverItem>
|
||||
)}
|
||||
{((showViewTags && onViewTags) || onCopyId) && <PopoverDivider />}
|
||||
|
||||
{/* Edit action */}
|
||||
{showEdit && onEdit && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -145,6 +158,14 @@ export function KnowledgeBaseContextMenu({
|
||||
Edit
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
{showDelete &&
|
||||
onDelete &&
|
||||
((showOpenInNewTab && onOpenInNewTab) ||
|
||||
(showViewTags && onViewTags) ||
|
||||
onCopyId ||
|
||||
(showEdit && onEdit)) && <PopoverDivider />}
|
||||
{showDelete && onDelete && (
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
interface LogRowContextMenuProps {
|
||||
@@ -50,7 +56,7 @@ export function LogRowContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Copy Execution ID */}
|
||||
{/* Copy action */}
|
||||
<PopoverItem
|
||||
disabled={!hasExecutionId}
|
||||
onClick={() => {
|
||||
@@ -61,7 +67,8 @@ export function LogRowContextMenu({
|
||||
Copy Execution ID
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Workflow */}
|
||||
{/* Navigation */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
disabled={!hasWorkflow}
|
||||
onClick={() => {
|
||||
@@ -72,7 +79,8 @@ export function LogRowContextMenu({
|
||||
Open Workflow
|
||||
</PopoverItem>
|
||||
|
||||
{/* Filter by Workflow - only show when not already filtered by this workflow */}
|
||||
{/* Filter actions */}
|
||||
<PopoverDivider />
|
||||
{!isFilteredByThisWorkflow && (
|
||||
<PopoverItem
|
||||
disabled={!hasWorkflow}
|
||||
@@ -84,8 +92,6 @@ export function LogRowContextMenu({
|
||||
Filter by Workflow
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Clear All Filters - show when any filters are active */}
|
||||
{hasActiveFilters && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
|
||||
@@ -185,6 +185,10 @@ export function NotificationSettings({
|
||||
|
||||
const hasSubscriptions = filteredSubscriptions.length > 0
|
||||
|
||||
// Compute form visibility synchronously to avoid empty state flash
|
||||
// Show form if user explicitly opened it OR if loading is complete with no subscriptions
|
||||
const displayForm = showForm || (!isLoading && !hasSubscriptions && !editingId)
|
||||
|
||||
const getSubscriptionsForTab = useCallback(
|
||||
(tab: NotificationType) => {
|
||||
return subscriptions.filter((s) => s.notificationType === tab)
|
||||
@@ -192,12 +196,6 @@ export function NotificationSettings({
|
||||
[subscriptions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !hasSubscriptions && !editingId) {
|
||||
setShowForm(true)
|
||||
}
|
||||
}, [isLoading, hasSubscriptions, editingId, activeTab])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
workflowIds: [],
|
||||
@@ -1210,7 +1208,7 @@ export function NotificationSettings({
|
||||
)
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (showForm) {
|
||||
if (displayForm) {
|
||||
return renderForm()
|
||||
}
|
||||
|
||||
@@ -1279,7 +1277,7 @@ export function NotificationSettings({
|
||||
</ModalTabs>
|
||||
|
||||
<ModalFooter>
|
||||
{showForm ? (
|
||||
{displayForm ? (
|
||||
<>
|
||||
{hasSubscriptions && (
|
||||
<Button
|
||||
|
||||
@@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
|
||||
{formattedContent && !formattedContent.startsWith('Uploaded') && (
|
||||
<div className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-all duration-200'>
|
||||
<div className='whitespace-pre-wrap break-words font-medium font-sans text-gray-100 text-sm leading-[1.25rem]'>
|
||||
<div className='whitespace-pre-wrap break-words font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem]'>
|
||||
<WordWrap text={formattedContent} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
|
||||
return (
|
||||
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
|
||||
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[#E8E8E8] text-sm leading-[1.25rem]'>
|
||||
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[var(--text-primary)] text-sm leading-[1.25rem]'>
|
||||
<WordWrap text={formattedContent} />
|
||||
{message.isStreaming && <StreamingIndicator />}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Badge,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
@@ -468,7 +468,7 @@ export function OutputSelect({
|
||||
disablePortal={disablePopoverPortal}
|
||||
>
|
||||
<div className='space-y-[2px]'>
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
|
||||
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
|
||||
|
||||
const firstOutput = outputs[0]
|
||||
@@ -489,12 +489,10 @@ export function OutputSelect({
|
||||
|
||||
return (
|
||||
<div key={blockName}>
|
||||
<PopoverSection>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<TagIcon icon={blockIcon} color={blockColor} />
|
||||
<span>{blockName}</span>
|
||||
</div>
|
||||
</PopoverSection>
|
||||
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
|
||||
<TagIcon icon={blockIcon} color={blockColor} />
|
||||
<span className='font-medium text-[13px]'>{blockName}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{outputs.map((output, localIndex) => {
|
||||
@@ -509,14 +507,13 @@ export function OutputSelect({
|
||||
onClick={() => handleOutputSelection(output.label)}
|
||||
onMouseEnter={() => setHighlightedIndex(globalIndex)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
|
||||
{output.path}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
|
||||
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { BlockContextMenuProps } from './types'
|
||||
|
||||
/**
|
||||
@@ -48,7 +54,13 @@ export function BlockContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -59,7 +71,7 @@ export function BlockContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Copy */}
|
||||
{/* Clipboard actions */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
onClick={() => {
|
||||
@@ -70,8 +82,6 @@ export function BlockContextMenu({
|
||||
<span>Copy</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘C</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Paste */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !hasClipboard}
|
||||
@@ -83,8 +93,6 @@ export function BlockContextMenu({
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Duplicate - hide for starter blocks */}
|
||||
{!hasStarterBlock && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -97,20 +105,8 @@ export function BlockContextMenu({
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Delete</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌫</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Enable/Disable - hide if all blocks are notes */}
|
||||
{/* Toggle and edit actions */}
|
||||
{!allNoteBlocks && <PopoverDivider />}
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -122,8 +118,6 @@ export function BlockContextMenu({
|
||||
{getToggleEnabledLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Flip Handles - hide if all blocks are notes */}
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -135,8 +129,6 @@ export function BlockContextMenu({
|
||||
Flip Handles
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Remove from Subflow - only show when applicable */}
|
||||
{canRemoveFromSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -149,7 +141,8 @@ export function BlockContextMenu({
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Rename - only for single block, not subflows */}
|
||||
{/* Single block actions */}
|
||||
{isSingleBlock && <PopoverDivider />}
|
||||
{isSingleBlock && !isSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -161,8 +154,6 @@ export function BlockContextMenu({
|
||||
Rename
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Open Editor - only for single block */}
|
||||
{isSingleBlock && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
@@ -173,6 +164,20 @@ export function BlockContextMenu({
|
||||
Open Editor
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Delete</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌫</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { PaneContextMenuProps } from './types'
|
||||
|
||||
/**
|
||||
@@ -28,7 +34,13 @@ export function PaneContextMenu({
|
||||
canRedo = false,
|
||||
}: PaneContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -39,7 +51,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Undo */}
|
||||
{/* History actions */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !canUndo}
|
||||
@@ -51,8 +63,6 @@ export function PaneContextMenu({
|
||||
<span>Undo</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘Z</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Redo */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !canRedo}
|
||||
@@ -65,7 +75,8 @@ export function PaneContextMenu({
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘⇧Z</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Paste */}
|
||||
{/* Edit and creation actions */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !hasClipboard}
|
||||
@@ -77,8 +88,6 @@ export function PaneContextMenu({
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Add Block */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
@@ -90,8 +99,6 @@ export function PaneContextMenu({
|
||||
<span>Add Block</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘K</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Auto-layout */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
@@ -104,7 +111,8 @@ export function PaneContextMenu({
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⇧L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Logs */}
|
||||
{/* Navigation actions */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
className='group'
|
||||
onClick={() => {
|
||||
@@ -115,8 +123,6 @@ export function PaneContextMenu({
|
||||
<span>Open Logs</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Variables */}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenVariables()
|
||||
@@ -125,8 +131,6 @@ export function PaneContextMenu({
|
||||
>
|
||||
Variables
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Chat */}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenChat()
|
||||
@@ -136,7 +140,8 @@ export function PaneContextMenu({
|
||||
Open Chat
|
||||
</PopoverItem>
|
||||
|
||||
{/* Invite to Workspace - admin only */}
|
||||
{/* Admin action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
disabled={disableAdmin}
|
||||
onClick={() => {
|
||||
|
||||
@@ -89,7 +89,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
|
||||
<span className='text-sm'>{href}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -7,6 +7,9 @@ import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workfl
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
/**
|
||||
* Constants for ComboBox component behavior
|
||||
@@ -48,6 +51,19 @@ interface ComboBoxProps {
|
||||
placeholder?: string
|
||||
/** Configuration for the sub-block */
|
||||
config: SubBlockConfig
|
||||
/** Async function to fetch options dynamically */
|
||||
fetchOptions?: (
|
||||
blockId: string,
|
||||
subBlockId: string
|
||||
) => Promise<Array<{ label: string; id: string }>>
|
||||
/** Async function to fetch a single option's label by ID (for hydration) */
|
||||
fetchOptionById?: (
|
||||
blockId: string,
|
||||
subBlockId: string,
|
||||
optionId: string
|
||||
) => Promise<{ label: string; id: string } | null>
|
||||
/** Field dependencies that trigger option refetch when changed */
|
||||
dependsOn?: SubBlockConfig['dependsOn']
|
||||
}
|
||||
|
||||
export function ComboBox({
|
||||
@@ -61,23 +77,89 @@ export function ComboBox({
|
||||
disabled,
|
||||
placeholder = 'Type or select an option...',
|
||||
config,
|
||||
fetchOptions,
|
||||
fetchOptionById,
|
||||
dependsOn,
|
||||
}: ComboBoxProps) {
|
||||
// Hooks and context
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const reactFlowInstance = useReactFlow()
|
||||
|
||||
// Dependency tracking for fetchOptions
|
||||
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const dependencyValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId]
|
||||
)
|
||||
)
|
||||
|
||||
// State management
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
|
||||
const previousDependencyValuesRef = useRef<string>('')
|
||||
|
||||
/**
|
||||
* Fetches options from the async fetchOptions function if provided
|
||||
*/
|
||||
const fetchOptionsIfNeeded = useCallback(async () => {
|
||||
if (!fetchOptions || isPreview || disabled) return
|
||||
|
||||
setIsLoadingOptions(true)
|
||||
setFetchError(null)
|
||||
try {
|
||||
const options = await fetchOptions(blockId, subBlockId)
|
||||
setFetchedOptions(options)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options'
|
||||
setFetchError(errorMessage)
|
||||
setFetchedOptions([])
|
||||
} finally {
|
||||
setIsLoadingOptions(false)
|
||||
}
|
||||
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
|
||||
|
||||
// Determine the active value based on mode (preview vs. controlled vs. store)
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
// Evaluate options if provided as a function
|
||||
const evaluatedOptions = useMemo(() => {
|
||||
// Evaluate static options if provided as a function
|
||||
const staticOptions = useMemo(() => {
|
||||
return typeof options === 'function' ? options() : options
|
||||
}, [options])
|
||||
|
||||
// Normalize fetched options to match ComboBoxOption format
|
||||
const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => {
|
||||
return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id }))
|
||||
}, [fetchedOptions])
|
||||
|
||||
// Merge static and fetched options - fetched options take priority when available
|
||||
const evaluatedOptions = useMemo((): ComboBoxOption[] => {
|
||||
let opts: ComboBoxOption[] =
|
||||
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
|
||||
|
||||
// Merge hydrated option if not already present
|
||||
if (hydratedOption) {
|
||||
const alreadyPresent = opts.some((o) =>
|
||||
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
|
||||
)
|
||||
if (!alreadyPresent) {
|
||||
opts = [hydratedOption, ...opts]
|
||||
}
|
||||
}
|
||||
|
||||
return opts
|
||||
}, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption])
|
||||
|
||||
// Convert options to Combobox format
|
||||
const comboboxOptions = useMemo((): ComboboxOption[] => {
|
||||
return evaluatedOptions.map((option) => {
|
||||
@@ -160,6 +242,94 @@ export function ComboBox({
|
||||
}
|
||||
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
|
||||
|
||||
// Clear fetched options and hydrated option when dependencies change
|
||||
useEffect(() => {
|
||||
if (fetchOptions && dependsOnFields.length > 0) {
|
||||
const currentDependencyValuesStr = JSON.stringify(dependencyValues)
|
||||
const previousDependencyValuesStr = previousDependencyValuesRef.current
|
||||
|
||||
if (
|
||||
previousDependencyValuesStr &&
|
||||
currentDependencyValuesStr !== previousDependencyValuesStr
|
||||
) {
|
||||
setFetchedOptions([])
|
||||
setHydratedOption(null)
|
||||
}
|
||||
|
||||
previousDependencyValuesRef.current = currentDependencyValuesStr
|
||||
}
|
||||
}, [dependencyValues, fetchOptions, dependsOnFields.length])
|
||||
|
||||
// Fetch options when needed (on mount, when enabled, or when dependencies change)
|
||||
useEffect(() => {
|
||||
if (
|
||||
fetchOptions &&
|
||||
!isPreview &&
|
||||
!disabled &&
|
||||
fetchedOptions.length === 0 &&
|
||||
!isLoadingOptions &&
|
||||
!fetchError
|
||||
) {
|
||||
fetchOptionsIfNeeded()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
|
||||
}, [
|
||||
fetchOptions,
|
||||
isPreview,
|
||||
disabled,
|
||||
fetchedOptions.length,
|
||||
isLoadingOptions,
|
||||
fetchError,
|
||||
dependencyValues,
|
||||
])
|
||||
|
||||
// Hydrate the stored value's label by fetching it individually
|
||||
useEffect(() => {
|
||||
if (!fetchOptionById || isPreview || disabled) return
|
||||
|
||||
const valueToHydrate = value as string | null | undefined
|
||||
if (!valueToHydrate) return
|
||||
|
||||
// Skip if value is an expression (not a real ID)
|
||||
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
|
||||
|
||||
// Skip if already hydrated with the same value
|
||||
if (hydratedOption?.id === valueToHydrate) return
|
||||
|
||||
// Skip if value is already in fetched options or static options
|
||||
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
|
||||
const alreadyInStaticOptions = staticOptions.some((opt) =>
|
||||
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
|
||||
)
|
||||
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
|
||||
|
||||
// Track if effect is still active (cleanup on unmount or value change)
|
||||
let isActive = true
|
||||
|
||||
// Fetch the hydrated option
|
||||
fetchOptionById(blockId, subBlockId, valueToHydrate)
|
||||
.then((option) => {
|
||||
if (isActive) setHydratedOption(option)
|
||||
})
|
||||
.catch(() => {
|
||||
if (isActive) setHydratedOption(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [
|
||||
fetchOptionById,
|
||||
value,
|
||||
blockId,
|
||||
subBlockId,
|
||||
isPreview,
|
||||
disabled,
|
||||
fetchedOptions,
|
||||
staticOptions,
|
||||
hydratedOption?.id,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles wheel event for ReactFlow zoom control
|
||||
* Intercepts Ctrl/Cmd+Wheel to zoom the canvas
|
||||
@@ -247,11 +417,13 @@ export function ComboBox({
|
||||
return option.id === newValue
|
||||
})
|
||||
|
||||
if (!matchedOption) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id
|
||||
// If a matching option is found, store its ID; otherwise store the raw value
|
||||
// (allows expressions like <block.output> to be entered directly)
|
||||
const nextValue = matchedOption
|
||||
? typeof matchedOption === 'string'
|
||||
? matchedOption
|
||||
: matchedOption.id
|
||||
: newValue
|
||||
setStoreValue(nextValue)
|
||||
}}
|
||||
isPreview={isPreview}
|
||||
@@ -293,6 +465,13 @@ export function ComboBox({
|
||||
onWheel: handleWheel,
|
||||
autoComplete: 'off',
|
||||
}}
|
||||
isLoading={isLoadingOptions}
|
||||
error={fetchError}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SubBlockInputController>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getCodeEditorProps,
|
||||
highlight,
|
||||
languages,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
@@ -74,6 +75,8 @@ interface ConditionInputProps {
|
||||
previewValue?: string | null
|
||||
/** Whether the component is disabled */
|
||||
disabled?: boolean
|
||||
/** Mode: 'condition' for code editor, 'router' for text input */
|
||||
mode?: 'condition' | 'router'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +104,9 @@ export function ConditionInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
mode = 'condition',
|
||||
}: ConditionInputProps) {
|
||||
const isRouterMode = mode === 'router'
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
@@ -161,32 +166,50 @@ export function ConditionInput({
|
||||
const shouldPersistRef = useRef<boolean>(false)
|
||||
|
||||
/**
|
||||
* Creates default if/else conditional blocks with stable IDs.
|
||||
* Creates default blocks with stable IDs.
|
||||
* For conditions: if/else blocks. For router: one route block.
|
||||
*
|
||||
* @returns Array of two default blocks (if and else)
|
||||
* @returns Array of default blocks
|
||||
*/
|
||||
const createDefaultBlocks = (): ConditionalBlock[] => [
|
||||
{
|
||||
id: generateStableId(blockId, 'if'),
|
||||
title: 'if',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
{
|
||||
id: generateStableId(blockId, 'else'),
|
||||
title: 'else',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
]
|
||||
const createDefaultBlocks = (): ConditionalBlock[] => {
|
||||
if (isRouterMode) {
|
||||
return [
|
||||
{
|
||||
id: generateStableId(blockId, 'route1'),
|
||||
title: 'route1',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: generateStableId(blockId, 'if'),
|
||||
title: 'if',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
{
|
||||
id: generateStableId(blockId, 'else'),
|
||||
title: 'else',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Initialize with a loading state instead of default blocks
|
||||
const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([])
|
||||
@@ -270,10 +293,13 @@ export function ConditionInput({
|
||||
const parsedBlocks = safeParseJSON(effectiveValueStr)
|
||||
|
||||
if (parsedBlocks) {
|
||||
const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({
|
||||
...block,
|
||||
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
|
||||
}))
|
||||
// For router mode, keep original titles. For condition mode, assign if/else if/else
|
||||
const blocksWithCorrectTitles = isRouterMode
|
||||
? parsedBlocks
|
||||
: parsedBlocks.map((block, index) => ({
|
||||
...block,
|
||||
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
|
||||
}))
|
||||
|
||||
setConditionalBlocks(blocksWithCorrectTitles)
|
||||
hasInitializedRef.current = true
|
||||
@@ -573,12 +599,17 @@ export function ConditionInput({
|
||||
|
||||
/**
|
||||
* Updates block titles based on their position in the array.
|
||||
* First block is always 'if', last is 'else', middle ones are 'else if'.
|
||||
* For conditions: First block is 'if', last is 'else', middle ones are 'else if'.
|
||||
* For router: Titles are user-editable and not auto-updated.
|
||||
*
|
||||
* @param blocks - Array of conditional blocks
|
||||
* @returns Updated blocks with correct titles
|
||||
*/
|
||||
const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => {
|
||||
if (isRouterMode) {
|
||||
// For router mode, don't change titles - they're user-editable
|
||||
return blocks
|
||||
}
|
||||
return blocks.map((block, index) => ({
|
||||
...block,
|
||||
title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if',
|
||||
@@ -590,13 +621,15 @@ export function ConditionInput({
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
|
||||
if (conditionalBlocks[blockIndex]?.title === 'else') return
|
||||
if (!isRouterMode && conditionalBlocks[blockIndex]?.title === 'else') return
|
||||
|
||||
const newBlockId = generateStableId(blockId, `else-if-${Date.now()}`)
|
||||
const newBlockId = isRouterMode
|
||||
? generateStableId(blockId, `route-${Date.now()}`)
|
||||
: generateStableId(blockId, `else-if-${Date.now()}`)
|
||||
|
||||
const newBlock: ConditionalBlock = {
|
||||
id: newBlockId,
|
||||
title: '',
|
||||
title: isRouterMode ? `route-${Date.now()}` : '',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
@@ -710,13 +743,15 @@ export function ConditionInput({
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-hidden bg-transparent px-[10px] py-[5px]',
|
||||
block.title === 'else'
|
||||
? 'rounded-[4px] border-0'
|
||||
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
|
||||
isRouterMode
|
||||
? 'rounded-t-[4px] border-[var(--border-1)] border-b'
|
||||
: block.title === 'else'
|
||||
? 'rounded-[4px] border-0'
|
||||
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{block.title}
|
||||
{isRouterMode ? `Route ${index + 1}` : block.title}
|
||||
</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Tooltip.Root>
|
||||
@@ -724,7 +759,7 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => addBlock(block.id)}
|
||||
disabled={isPreview || disabled || block.title === 'else'}
|
||||
disabled={isPreview || disabled || (!isRouterMode && block.title === 'else')}
|
||||
className='h-auto p-0'
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
@@ -739,7 +774,12 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => moveBlock(block.id, 'up')}
|
||||
disabled={isPreview || index === 0 || disabled || block.title === 'else'}
|
||||
disabled={
|
||||
isPreview ||
|
||||
index === 0 ||
|
||||
disabled ||
|
||||
(!isRouterMode && block.title === 'else')
|
||||
}
|
||||
className='h-auto p-0'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
@@ -758,8 +798,8 @@ export function ConditionInput({
|
||||
isPreview ||
|
||||
disabled ||
|
||||
index === conditionalBlocks.length - 1 ||
|
||||
conditionalBlocks[index + 1]?.title === 'else' ||
|
||||
block.title === 'else'
|
||||
(!isRouterMode && conditionalBlocks[index + 1]?.title === 'else') ||
|
||||
(!isRouterMode && block.title === 'else')
|
||||
}
|
||||
className='h-auto p-0'
|
||||
>
|
||||
@@ -775,18 +815,122 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeBlock(block.id)}
|
||||
disabled={isPreview || conditionalBlocks.length === 1 || disabled}
|
||||
disabled={isPreview || disabled || conditionalBlocks.length === 1}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Delete Block</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete Condition</Tooltip.Content>
|
||||
<Tooltip.Content>
|
||||
{isRouterMode ? 'Delete Route' : 'Delete Condition'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{block.title !== 'else' &&
|
||||
{/* Router mode: show description textarea with tag/env var support */}
|
||||
{isRouterMode && (
|
||||
<div
|
||||
className='relative'
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDrop(block.id, e)}
|
||||
>
|
||||
<Textarea
|
||||
data-router-block-id={block.id}
|
||||
value={block.value}
|
||||
onChange={(e) => {
|
||||
if (!isPreview && !disabled) {
|
||||
const newValue = e.target.value
|
||||
const pos = e.target.selectionStart ?? 0
|
||||
|
||||
const tagTrigger = checkTagTrigger(newValue, pos)
|
||||
const envVarTrigger = checkEnvVarTrigger(newValue, pos)
|
||||
|
||||
shouldPersistRef.current = true
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id
|
||||
? {
|
||||
...b,
|
||||
value: newValue,
|
||||
showTags: tagTrigger.show,
|
||||
showEnvVars: envVarTrigger.show,
|
||||
searchTerm: envVarTrigger.show ? envVarTrigger.searchTerm : '',
|
||||
cursorPosition: pos,
|
||||
}
|
||||
: b
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id ? { ...b, showTags: false, showEnvVars: false } : b
|
||||
)
|
||||
)
|
||||
}, 150)
|
||||
}}
|
||||
placeholder='Describe when this route should be taken...'
|
||||
disabled={disabled || isPreview}
|
||||
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{block.showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={block.showEnvVars}
|
||||
onSelect={(newValue) => handleEnvVarSelectImmediate(block.id, newValue)}
|
||||
searchTerm={block.searchTerm}
|
||||
inputValue={block.value}
|
||||
cursorPosition={block.cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id
|
||||
? {
|
||||
...b,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
}
|
||||
: b
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{block.showTags && (
|
||||
<TagDropdown
|
||||
visible={block.showTags}
|
||||
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={block.activeSourceBlockId}
|
||||
inputValue={block.value}
|
||||
cursorPosition={block.cursorPosition}
|
||||
onClose={() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id
|
||||
? {
|
||||
...b,
|
||||
showTags: false,
|
||||
activeSourceBlockId: null,
|
||||
}
|
||||
: b
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition mode: show code editor */}
|
||||
{!isRouterMode &&
|
||||
block.title !== 'else' &&
|
||||
(() => {
|
||||
const blockLineCount = block.value.split('\n').length
|
||||
const blockGutterWidth = calculateGutterWidth(blockLineCount)
|
||||
|
||||
@@ -44,6 +44,12 @@ interface DropdownProps {
|
||||
blockId: string,
|
||||
subBlockId: string
|
||||
) => Promise<Array<{ label: string; id: string }>>
|
||||
/** Async function to fetch a single option's label by ID (for hydration) */
|
||||
fetchOptionById?: (
|
||||
blockId: string,
|
||||
subBlockId: string,
|
||||
optionId: string
|
||||
) => Promise<{ label: string; id: string } | null>
|
||||
/** Field dependencies that trigger option refetch when changed */
|
||||
dependsOn?: SubBlockConfig['dependsOn']
|
||||
/** Enable search input in dropdown */
|
||||
@@ -71,6 +77,7 @@ export function Dropdown({
|
||||
placeholder = 'Select an option...',
|
||||
multiSelect = false,
|
||||
fetchOptions,
|
||||
fetchOptionById,
|
||||
dependsOn,
|
||||
searchable = false,
|
||||
}: DropdownProps) {
|
||||
@@ -98,6 +105,7 @@ export function Dropdown({
|
||||
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
|
||||
|
||||
const previousModeRef = useRef<string | null>(null)
|
||||
const previousDependencyValuesRef = useRef<string>('')
|
||||
@@ -150,11 +158,23 @@ export function Dropdown({
|
||||
}, [fetchedOptions])
|
||||
|
||||
const availableOptions = useMemo(() => {
|
||||
if (fetchOptions && normalizedFetchedOptions.length > 0) {
|
||||
return normalizedFetchedOptions
|
||||
let opts: DropdownOption[] =
|
||||
fetchOptions && normalizedFetchedOptions.length > 0
|
||||
? normalizedFetchedOptions
|
||||
: evaluatedOptions
|
||||
|
||||
// Merge hydrated option if not already present
|
||||
if (hydratedOption) {
|
||||
const alreadyPresent = opts.some((o) =>
|
||||
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
|
||||
)
|
||||
if (!alreadyPresent) {
|
||||
opts = [hydratedOption, ...opts]
|
||||
}
|
||||
}
|
||||
return evaluatedOptions
|
||||
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions])
|
||||
|
||||
return opts
|
||||
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
|
||||
|
||||
/**
|
||||
* Convert dropdown options to Combobox format
|
||||
@@ -310,7 +330,7 @@ export function Dropdown({
|
||||
)
|
||||
|
||||
/**
|
||||
* Effect to clear fetched options when dependencies actually change
|
||||
* Effect to clear fetched options and hydrated option when dependencies actually change
|
||||
* This ensures options are refetched with new dependency values (e.g., new credentials)
|
||||
*/
|
||||
useEffect(() => {
|
||||
@@ -323,6 +343,7 @@ export function Dropdown({
|
||||
currentDependencyValuesStr !== previousDependencyValuesStr
|
||||
) {
|
||||
setFetchedOptions([])
|
||||
setHydratedOption(null)
|
||||
}
|
||||
|
||||
previousDependencyValuesRef.current = currentDependencyValuesStr
|
||||
@@ -338,18 +359,72 @@ export function Dropdown({
|
||||
!isPreview &&
|
||||
!disabled &&
|
||||
fetchedOptions.length === 0 &&
|
||||
!isLoadingOptions
|
||||
!isLoadingOptions &&
|
||||
!fetchError
|
||||
) {
|
||||
fetchOptionsIfNeeded()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
|
||||
}, [
|
||||
fetchOptions,
|
||||
isPreview,
|
||||
disabled,
|
||||
fetchedOptions.length,
|
||||
isLoadingOptions,
|
||||
fetchOptionsIfNeeded,
|
||||
dependencyValues, // Refetch when dependencies change
|
||||
fetchError,
|
||||
dependencyValues,
|
||||
])
|
||||
|
||||
/**
|
||||
* Effect to hydrate the stored value's label by fetching it individually
|
||||
* This ensures the correct label is shown before the full options list loads
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!fetchOptionById || isPreview || disabled) return
|
||||
|
||||
// Get the value to hydrate (single value only, not multi-select)
|
||||
const valueToHydrate = multiSelect ? null : (singleValue as string | null | undefined)
|
||||
if (!valueToHydrate) return
|
||||
|
||||
// Skip if value is an expression (not a real ID)
|
||||
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
|
||||
|
||||
// Skip if already hydrated with the same value
|
||||
if (hydratedOption?.id === valueToHydrate) return
|
||||
|
||||
// Skip if value is already in fetched options or static options
|
||||
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
|
||||
const alreadyInStaticOptions = evaluatedOptions.some((opt) =>
|
||||
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
|
||||
)
|
||||
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
|
||||
|
||||
// Track if effect is still active (cleanup on unmount or value change)
|
||||
let isActive = true
|
||||
|
||||
// Fetch the hydrated option
|
||||
fetchOptionById(blockId, subBlockId, valueToHydrate)
|
||||
.then((option) => {
|
||||
if (isActive) setHydratedOption(option)
|
||||
})
|
||||
.catch(() => {
|
||||
if (isActive) setHydratedOption(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [
|
||||
fetchOptionById,
|
||||
singleValue,
|
||||
multiSelect,
|
||||
blockId,
|
||||
subBlockId,
|
||||
isPreview,
|
||||
disabled,
|
||||
fetchedOptions,
|
||||
evaluatedOptions,
|
||||
hydratedOption?.id,
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PopoverAnchor,
|
||||
PopoverBackButton,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverFolder,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
@@ -754,6 +755,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
|
||||
}
|
||||
} else if (sourceBlock.type === 'human_in_the_loop') {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
|
||||
const isSelfReference = activeSourceBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
// For self-reference, only show url and resumeEndpoint (not response format fields)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
}
|
||||
} else {
|
||||
const operationValue =
|
||||
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
|
||||
@@ -1073,7 +1092,19 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
|
||||
}
|
||||
} else if (accessibleBlock.type === 'human_in_the_loop') {
|
||||
blockTags = [`${normalizedBlockName}.url`]
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
|
||||
const isSelfReference = accessibleBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
// For self-reference, only show url and resumeEndpoint (not response format fields)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
|
||||
}
|
||||
} else {
|
||||
const operationValue =
|
||||
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
|
||||
@@ -1426,7 +1457,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
|
||||
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn('pointer-events-none', className)}
|
||||
@@ -1502,23 +1533,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
<span className='flex-1 truncate'>
|
||||
{tag.startsWith(TAG_PREFIXES.VARIABLE)
|
||||
? tag.substring(TAG_PREFIXES.VARIABLE.length)
|
||||
: tag}
|
||||
</span>
|
||||
{variableInfo && (
|
||||
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
|
||||
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
|
||||
{variableInfo.type}
|
||||
</span>
|
||||
)}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
{nestedBlockTagGroups.length > 0 && <PopoverDivider rootOnly />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{nestedBlockTagGroups.map((group: NestedBlockTagGroup) => {
|
||||
{nestedBlockTagGroups.map((group: NestedBlockTagGroup, groupIndex: number) => {
|
||||
const blockConfig = getBlock(group.blockType)
|
||||
let blockColor = blockConfig?.bgColor || BLOCK_COLORS.DEFAULT
|
||||
|
||||
@@ -1565,9 +1597,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}}
|
||||
>
|
||||
<TagIcon icon={tagIcon} color={blockColor} />
|
||||
<span className='flex-1 truncate font-medium text-[var(--text-primary)]'>
|
||||
{group.blockName}
|
||||
</span>
|
||||
<span className='flex-1 truncate font-medium'>{group.blockName}</span>
|
||||
</PopoverItem>
|
||||
{group.nestedTags.map((nestedTag) => {
|
||||
if (nestedTag.fullTag === rootTag) {
|
||||
@@ -1650,11 +1680,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
{child.display}
|
||||
</span>
|
||||
<span className='flex-1 truncate'>{child.display}</span>
|
||||
{childType && childType !== 'any' && (
|
||||
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
|
||||
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
|
||||
{childType}
|
||||
</span>
|
||||
)}
|
||||
@@ -1722,17 +1750,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
{nestedTag.display}
|
||||
</span>
|
||||
<span className='flex-1 truncate'>{nestedTag.display}</span>
|
||||
{tagDescription && tagDescription !== 'any' && (
|
||||
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
|
||||
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
|
||||
{tagDescription}
|
||||
</span>
|
||||
)}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
{groupIndex < nestedBlockTagGroups.length - 1 && <PopoverDivider rootOnly />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -760,6 +760,7 @@ function CodeEditorSyncWrapper({
|
||||
* in the tool selection dropdown.
|
||||
*/
|
||||
const BUILT_IN_TOOL_TYPES = new Set([
|
||||
'api',
|
||||
'file',
|
||||
'function',
|
||||
'knowledge',
|
||||
@@ -772,6 +773,7 @@ const BUILT_IN_TOOL_TYPES = new Set([
|
||||
'tts',
|
||||
'stt',
|
||||
'memory',
|
||||
'webhook_request',
|
||||
'workflow',
|
||||
])
|
||||
|
||||
@@ -926,6 +928,8 @@ export function ToolInput({
|
||||
const toolBlocks = getAllBlocks().filter(
|
||||
(block) =>
|
||||
(block.category === 'tools' ||
|
||||
block.type === 'api' ||
|
||||
block.type === 'webhook_request' ||
|
||||
block.type === 'workflow' ||
|
||||
block.type === 'knowledge' ||
|
||||
block.type === 'function') &&
|
||||
|
||||
@@ -38,6 +38,27 @@ const DEFAULT_ASSIGNMENT: Omit<VariableAssignment, 'id'> = {
|
||||
isExisting: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a value that might be a JSON string or already an array of VariableAssignment.
|
||||
* This handles the case where workflows are imported with stringified values.
|
||||
*/
|
||||
function parseVariableAssignments(value: unknown): VariableAssignment[] {
|
||||
if (!value) return []
|
||||
if (Array.isArray(value)) return value as VariableAssignment[]
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (Array.isArray(parsed)) return parsed as VariableAssignment[]
|
||||
} catch {
|
||||
// Not valid JSON, return empty array
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function VariablesInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
@@ -64,8 +85,8 @@ export function VariablesInput({
|
||||
(v: Variable) => v.workflowId === workflowId
|
||||
)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const assignments: VariableAssignment[] = value || []
|
||||
const rawValue = isPreview ? previewValue : storeValue
|
||||
const assignments: VariableAssignment[] = parseVariableAssignments(rawValue)
|
||||
const isReadOnly = isPreview || disabled
|
||||
|
||||
const getAvailableVariablesFor = (currentAssignmentId: string) => {
|
||||
|
||||
@@ -460,6 +460,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
multiSelect={config.multiSelect}
|
||||
fetchOptions={config.fetchOptions}
|
||||
fetchOptionById={config.fetchOptionById}
|
||||
dependsOn={config.dependsOn}
|
||||
searchable={config.searchable}
|
||||
/>
|
||||
@@ -479,6 +480,9 @@ function SubBlockComponent({
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
config={config}
|
||||
fetchOptions={config.fetchOptions}
|
||||
fetchOptionById={config.fetchOptionById}
|
||||
dependsOn={config.dependsOn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -605,6 +609,18 @@ function SubBlockComponent({
|
||||
/>
|
||||
)
|
||||
|
||||
case 'router-input':
|
||||
return (
|
||||
<ConditionInput
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
mode='router'
|
||||
/>
|
||||
)
|
||||
|
||||
case 'eval-input':
|
||||
return (
|
||||
<EvalInput
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { LogRowContextMenu } from './log-row-context-menu'
|
||||
export { OutputContextMenu } from './output-context-menu'
|
||||
export { PrettierOutput } from './prettier-output'
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
|
||||
interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface TerminalFilters {
|
||||
blockIds: Set<string>
|
||||
statuses: Set<'error' | 'info'>
|
||||
runIds: Set<string>
|
||||
}
|
||||
|
||||
interface LogRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: ContextMenuPosition
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
entry: ConsoleEntry | null
|
||||
filters: TerminalFilters
|
||||
onFilterByBlock: (blockId: string) => void
|
||||
onFilterByStatus: (status: 'error' | 'info') => void
|
||||
onFilterByRunId: (runId: string) => void
|
||||
onClearFilters: () => void
|
||||
onClearConsole: () => void
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for terminal log rows (left side).
|
||||
* Displays filtering options based on the selected row's properties.
|
||||
*/
|
||||
export function LogRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
entry,
|
||||
filters,
|
||||
onFilterByBlock,
|
||||
onFilterByStatus,
|
||||
onFilterByRunId,
|
||||
onClearFilters,
|
||||
onClearConsole,
|
||||
hasActiveFilters,
|
||||
}: LogRowContextMenuProps) {
|
||||
const hasRunId = entry?.executionId != null
|
||||
|
||||
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
|
||||
const entryStatus = entry?.success ? 'info' : 'error'
|
||||
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
|
||||
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Clear filters at top when active */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearFilters()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
</PopoverItem>
|
||||
{entry && <PopoverDivider />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Filter actions */}
|
||||
{entry && (
|
||||
<>
|
||||
<PopoverItem
|
||||
showCheck={isBlockFiltered}
|
||||
onClick={() => {
|
||||
onFilterByBlock(entry.blockId)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Filter by Block
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
showCheck={isStatusFiltered}
|
||||
onClick={() => {
|
||||
onFilterByStatus(entryStatus)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Filter by Status
|
||||
</PopoverItem>
|
||||
{hasRunId && (
|
||||
<PopoverItem
|
||||
showCheck={isRunIdFiltered}
|
||||
onClick={() => {
|
||||
onFilterByRunId(entry.executionId!)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Filter by Run ID
|
||||
</PopoverItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
{(entry || hasActiveFilters) && <PopoverDivider />}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearConsole()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Clear Console
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface OutputContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: ContextMenuPosition
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
onCopySelection: () => void
|
||||
onCopyAll: () => void
|
||||
onSearch: () => void
|
||||
wrapText: boolean
|
||||
onToggleWrap: () => void
|
||||
openOnRun: boolean
|
||||
onToggleOpenOnRun: () => void
|
||||
onClearConsole: () => void
|
||||
hasSelection: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for terminal output panel (right side).
|
||||
* Displays copy, search, and display options for the code viewer.
|
||||
*/
|
||||
export function OutputContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onCopySelection,
|
||||
onCopyAll,
|
||||
onSearch,
|
||||
wrapText,
|
||||
onToggleWrap,
|
||||
openOnRun,
|
||||
onToggleOpenOnRun,
|
||||
onClearConsole,
|
||||
hasSelection,
|
||||
}: OutputContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Copy and search actions */}
|
||||
<PopoverItem
|
||||
disabled={!hasSelection}
|
||||
onClick={() => {
|
||||
onCopySelection()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Copy Selection
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopyAll()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Copy All
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onSearch()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</PopoverItem>
|
||||
|
||||
{/* Display settings - toggles don't close menu */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
|
||||
Wrap Text
|
||||
</PopoverItem>
|
||||
<PopoverItem showCheck={openOnRun} onClick={onToggleOpenOnRun}>
|
||||
Open on Run
|
||||
</PopoverItem>
|
||||
|
||||
{/* Destructive action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearConsole()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Clear Console
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -38,11 +38,16 @@ import {
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
LogRowContextMenu,
|
||||
OutputContextMenu,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components'
|
||||
import {
|
||||
useOutputPanelResize,
|
||||
useTerminalFilters,
|
||||
useTerminalResize,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
@@ -365,6 +370,28 @@ export function Terminal() {
|
||||
hasActiveFilters,
|
||||
} = useTerminalFilters()
|
||||
|
||||
// Context menu state
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
const [contextMenuEntry, setContextMenuEntry] = useState<ConsoleEntry | null>(null)
|
||||
const [storedSelectionText, setStoredSelectionText] = useState('')
|
||||
|
||||
// Context menu hooks
|
||||
const {
|
||||
isOpen: isLogRowMenuOpen,
|
||||
position: logRowMenuPosition,
|
||||
menuRef: logRowMenuRef,
|
||||
handleContextMenu: handleLogRowContextMenu,
|
||||
closeMenu: closeLogRowMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const {
|
||||
isOpen: isOutputMenuOpen,
|
||||
position: outputMenuPosition,
|
||||
menuRef: outputMenuRef,
|
||||
handleContextMenu: handleOutputContextMenu,
|
||||
closeMenu: closeOutputMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
/**
|
||||
* Expands the terminal to its last meaningful height, with safeguards:
|
||||
* - Never expands below {@link DEFAULT_EXPANDED_HEIGHT}.
|
||||
@@ -511,15 +538,11 @@ export function Terminal() {
|
||||
const handleRowClick = useCallback((entry: ConsoleEntry) => {
|
||||
setSelectedEntry((prev) => {
|
||||
const isDeselecting = prev?.id === entry.id
|
||||
// Re-enable auto-select when deselecting, disable when selecting
|
||||
setAutoSelectEnabled(isDeselecting)
|
||||
return isDeselecting ? null : entry
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle header click - toggle between expanded and collapsed
|
||||
*/
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
if (isExpanded) {
|
||||
setIsToggling(true)
|
||||
@@ -529,16 +552,10 @@ export function Terminal() {
|
||||
}
|
||||
}, [expandToLastHeight, isExpanded, setTerminalHeight])
|
||||
|
||||
/**
|
||||
* Handle transition end - reset toggling state
|
||||
*/
|
||||
const handleTransitionEnd = useCallback(() => {
|
||||
setIsToggling(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle copy output to clipboard
|
||||
*/
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!selectedEntry) return
|
||||
|
||||
@@ -560,9 +577,6 @@ export function Terminal() {
|
||||
}
|
||||
}, [activeWorkflowId, clearWorkflowConsole])
|
||||
|
||||
/**
|
||||
* Activates output search and focuses the search input.
|
||||
*/
|
||||
const activateOutputSearch = useCallback(() => {
|
||||
setIsOutputSearchActive(true)
|
||||
setTimeout(() => {
|
||||
@@ -570,9 +584,6 @@ export function Terminal() {
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Closes output search and clears the query.
|
||||
*/
|
||||
const closeOutputSearch = useCallback(() => {
|
||||
setIsOutputSearchActive(false)
|
||||
setOutputSearchQuery('')
|
||||
@@ -604,9 +615,6 @@ export function Terminal() {
|
||||
setCurrentMatchIndex(0)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle clear console for current workflow via mouse interaction.
|
||||
*/
|
||||
const handleClearConsole = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -615,10 +623,6 @@ export function Terminal() {
|
||||
[clearCurrentWorkflowConsole]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle export of console entries for the current workflow via mouse interaction.
|
||||
* Mirrors the visibility and interaction behavior of the clear console action.
|
||||
*/
|
||||
const handleExportConsole = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -629,9 +633,60 @@ export function Terminal() {
|
||||
[activeWorkflowId, exportConsoleCSV]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle training button click - toggle training state or open modal
|
||||
*/
|
||||
const handleCopySelection = useCallback(() => {
|
||||
if (storedSelectionText) {
|
||||
navigator.clipboard.writeText(storedSelectionText)
|
||||
setShowCopySuccess(true)
|
||||
}
|
||||
}, [storedSelectionText])
|
||||
|
||||
const handleOutputPanelContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const selection = window.getSelection()
|
||||
const selectionText = selection?.toString() || ''
|
||||
setStoredSelectionText(selectionText)
|
||||
setHasSelection(selectionText.length > 0)
|
||||
handleOutputContextMenu(e)
|
||||
},
|
||||
[handleOutputContextMenu]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, entry: ConsoleEntry) => {
|
||||
setContextMenuEntry(entry)
|
||||
handleLogRowContextMenu(e)
|
||||
},
|
||||
[handleLogRowContextMenu]
|
||||
)
|
||||
|
||||
const handleFilterByBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
toggleBlock(blockId)
|
||||
closeLogRowMenu()
|
||||
},
|
||||
[toggleBlock, closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleFilterByStatus = useCallback(
|
||||
(status: 'error' | 'info') => {
|
||||
toggleStatus(status)
|
||||
closeLogRowMenu()
|
||||
},
|
||||
[toggleStatus, closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleFilterByRunId = useCallback(
|
||||
(runId: string) => {
|
||||
toggleRunId(runId)
|
||||
closeLogRowMenu()
|
||||
},
|
||||
[toggleRunId, closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleClearConsoleFromMenu = useCallback(() => {
|
||||
clearCurrentWorkflowConsole()
|
||||
}, [clearCurrentWorkflowConsole])
|
||||
|
||||
const handleTrainingClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -644,9 +699,6 @@ export function Terminal() {
|
||||
[isTraining, stopTraining, toggleTrainingModal]
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether training controls should be visible
|
||||
*/
|
||||
const shouldShowTrainingButton = isTrainingEnvEnabled && showTrainingControls
|
||||
|
||||
/**
|
||||
@@ -721,6 +773,23 @@ export function Terminal() {
|
||||
}
|
||||
}, [showCopySuccess])
|
||||
|
||||
/**
|
||||
* Track text selection state for context menu.
|
||||
* Skip updates when the context menu is open to prevent the selection
|
||||
* state from changing mid-click (which would disable the copy button).
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
if (isOutputMenuOpen) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
setHasSelection(Boolean(selection && selection.toString().length > 0))
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', handleSelectionChange)
|
||||
return () => document.removeEventListener('selectionchange', handleSelectionChange)
|
||||
}, [isOutputMenuOpen])
|
||||
|
||||
/**
|
||||
* Auto-select the latest entry when new logs arrive
|
||||
* Re-enables auto-selection when all entries are cleared
|
||||
@@ -1311,6 +1380,7 @@ export function Terminal() {
|
||||
isSelected && 'bg-[var(--surface-6)] dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
onClick={() => handleRowClick(entry)}
|
||||
onContextMenu={(e) => handleRowContextMenu(e, entry)}
|
||||
>
|
||||
{/* Block */}
|
||||
<div
|
||||
@@ -1327,7 +1397,13 @@ export function Terminal() {
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS)}>
|
||||
<div
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.STATUS,
|
||||
COLUMN_BASE_CLASS,
|
||||
'flex items-center'
|
||||
)}
|
||||
>
|
||||
{statusInfo ? (
|
||||
<Badge variant={statusInfo.isError ? 'red' : 'gray'} dot>
|
||||
{statusInfo.label}
|
||||
@@ -1719,7 +1795,10 @@ export function Terminal() {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}>
|
||||
<div
|
||||
className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}
|
||||
onContextMenu={handleOutputPanelContextMenu}
|
||||
>
|
||||
{shouldShowCodeDisplay ? (
|
||||
<OutputCodeContent
|
||||
code={selectedEntry.input.code}
|
||||
@@ -1748,6 +1827,42 @@ export function Terminal() {
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Log Row Context Menu */}
|
||||
<LogRowContextMenu
|
||||
isOpen={isLogRowMenuOpen}
|
||||
position={logRowMenuPosition}
|
||||
menuRef={logRowMenuRef}
|
||||
onClose={closeLogRowMenu}
|
||||
entry={contextMenuEntry}
|
||||
filters={filters}
|
||||
onFilterByBlock={handleFilterByBlock}
|
||||
onFilterByStatus={handleFilterByStatus}
|
||||
onFilterByRunId={handleFilterByRunId}
|
||||
onClearFilters={() => {
|
||||
clearFilters()
|
||||
closeLogRowMenu()
|
||||
}}
|
||||
onClearConsole={handleClearConsoleFromMenu}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
|
||||
{/* Output Panel Context Menu */}
|
||||
<OutputContextMenu
|
||||
isOpen={isOutputMenuOpen}
|
||||
position={outputMenuPosition}
|
||||
menuRef={outputMenuRef}
|
||||
onClose={closeOutputMenu}
|
||||
onCopySelection={handleCopySelection}
|
||||
onCopyAll={handleCopy}
|
||||
onSearch={activateOutputSearch}
|
||||
wrapText={wrapText}
|
||||
onToggleWrap={() => setWrapText(!wrapText)}
|
||||
openOnRun={openOnRun}
|
||||
onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)}
|
||||
onClearConsole={handleClearConsoleFromMenu}
|
||||
hasSelection={hasSelection}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -841,6 +841,37 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
]
|
||||
}, [type, subBlockState, id])
|
||||
|
||||
/**
|
||||
* Compute per-route rows (id/value) for router_v2 blocks so we can render
|
||||
* one row per route with its own output handle.
|
||||
* Uses same structure as conditions: { id, title, value }
|
||||
*/
|
||||
const routerRows = useMemo(() => {
|
||||
if (type !== 'router_v2') return [] as { id: string; value: string }[]
|
||||
|
||||
const routesValue = subBlockState.routes?.value
|
||||
const raw = typeof routesValue === 'string' ? routesValue : undefined
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const routeItem = item as { id?: string; value?: string }
|
||||
return {
|
||||
id: routeItem?.id ?? `${id}-route-${index}`,
|
||||
value: routeItem?.value ?? '',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse router routes value', { error, blockId: id })
|
||||
}
|
||||
|
||||
return [{ id: `${id}-route-route1`, value: '' }]
|
||||
}, [type, subBlockState, id])
|
||||
|
||||
/**
|
||||
* Compute and publish deterministic layout metrics for workflow blocks.
|
||||
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
|
||||
@@ -857,6 +888,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
let rowsCount = 0
|
||||
if (type === 'condition') {
|
||||
rowsCount = conditionRows.length + defaultHandlesRow
|
||||
} else if (type === 'router_v2') {
|
||||
// +1 for context row, plus route rows
|
||||
rowsCount = 1 + routerRows.length + defaultHandlesRow
|
||||
} else {
|
||||
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
|
||||
rowsCount = subblockRowCount + defaultHandlesRow
|
||||
@@ -879,6 +913,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
displayTriggerMode,
|
||||
subBlockRows.length,
|
||||
conditionRows.length,
|
||||
routerRows.length,
|
||||
horizontalHandles,
|
||||
],
|
||||
})
|
||||
@@ -1025,7 +1060,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
Webhook
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-4'>
|
||||
<Tooltip.Content side='top' className='max-w-[300px]'>
|
||||
{webhookProvider && webhookPath ? (
|
||||
<>
|
||||
<p className='text-sm'>{getProviderName(webhookProvider)} Webhook</p>
|
||||
@@ -1073,32 +1108,45 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{type === 'condition'
|
||||
? conditionRows.map((cond) => (
|
||||
{type === 'condition' ? (
|
||||
conditionRows.map((cond) => (
|
||||
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
|
||||
))
|
||||
) : type === 'router_v2' ? (
|
||||
<>
|
||||
<SubBlockRow
|
||||
key='context'
|
||||
title='Context'
|
||||
value={getDisplayValue(subBlockState.context?.value)}
|
||||
/>
|
||||
{routerRows.map((route, index) => (
|
||||
<SubBlockRow
|
||||
key={cond.id}
|
||||
title={cond.title}
|
||||
value={getDisplayValue(cond.value)}
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
value={getDisplayValue(route.value)}
|
||||
/>
|
||||
))
|
||||
: subBlockRows.map((row, rowIndex) =>
|
||||
row.map((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
return (
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${rowIndex}`}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
rawValue={rawValue}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
subBlockRows.map((row, rowIndex) =>
|
||||
row.map((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
return (
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${rowIndex}`}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
rawValue={rawValue}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)
|
||||
)}
|
||||
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
|
||||
</div>
|
||||
)}
|
||||
@@ -1153,7 +1201,58 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
</>
|
||||
)}
|
||||
|
||||
{type !== 'condition' && type !== 'response' && (
|
||||
{type === 'router_v2' && (
|
||||
<>
|
||||
{routerRows.map((route, routeIndex) => {
|
||||
// +1 row offset for context row at the top
|
||||
const topOffset =
|
||||
HANDLE_POSITIONS.CONDITION_START_Y +
|
||||
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
|
||||
return (
|
||||
<Handle
|
||||
key={`handle-${route.id}`}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={`router-${route.id}`}
|
||||
className={getHandleClasses('right')}
|
||||
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
data-nodeid={id}
|
||||
data-handleid={`router-${route.id}`}
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
|
||||
<>
|
||||
<Handle
|
||||
type='source'
|
||||
|
||||
@@ -165,7 +165,7 @@ const reactFlowStyles = [
|
||||
'[&_.react-flow__renderer]:!bg-transparent',
|
||||
'[&_.react-flow__background]:hidden',
|
||||
].join(' ')
|
||||
const reactFlowFitViewOptions = { padding: 0.6 } as const
|
||||
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
|
||||
const reactFlowProOptions = { hideAttribution: true } as const
|
||||
|
||||
interface SelectedEdgeInfo {
|
||||
@@ -478,7 +478,7 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Connection line style - red for error handles, default otherwise. */
|
||||
const connectionLineStyle = useMemo(
|
||||
() => ({
|
||||
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--surface-7)',
|
||||
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--workflow-edge)',
|
||||
strokeWidth: 2,
|
||||
}),
|
||||
[isErrorConnectionDrag]
|
||||
@@ -795,6 +795,13 @@ const WorkflowContent = React.memo(() => {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
|
||||
const selection = window.getSelection()
|
||||
const hasTextSelection = selection && selection.toString().length > 0
|
||||
|
||||
if (hasTextSelection) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedNodes = getNodes().filter((node) => node.selected)
|
||||
if (selectedNodes.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -21,6 +21,7 @@ import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/hooks/use-profile-picture-upload'
|
||||
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
@@ -80,6 +81,12 @@ function GeneralSkeleton() {
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* Snap to grid row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
{/* Telemetry row */}
|
||||
<div className='flex items-center justify-between border-t pt-[16px]'>
|
||||
<Skeleton className='h-4 w-44' />
|
||||
@@ -87,13 +94,16 @@ function GeneralSkeleton() {
|
||||
</div>
|
||||
|
||||
{/* Telemetry description */}
|
||||
<Skeleton className='h-[12px] w-full' />
|
||||
<Skeleton className='-mt-2 h-[12px] w-4/5' />
|
||||
<div className='-mt-[8px] flex flex-col gap-1'>
|
||||
<Skeleton className='h-[12px] w-full' />
|
||||
<Skeleton className='h-[12px] w-4/5' />
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='mt-auto flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-8 w-20 rounded-[4px]' />
|
||||
<Skeleton className='h-8 w-28 rounded-[4px]' />
|
||||
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -556,13 +566,15 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => window.open('/?from=settings', '_blank', 'noopener,noreferrer')}
|
||||
variant='active'
|
||||
className='ml-auto'
|
||||
>
|
||||
Home Page
|
||||
</Button>
|
||||
{isHosted && (
|
||||
<Button
|
||||
onClick={() => window.open('/?from=settings', '_blank', 'noopener,noreferrer')}
|
||||
variant='active'
|
||||
className='ml-auto'
|
||||
>
|
||||
Home Page
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Reset Confirmation Modal */}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface ContextMenuProps {
|
||||
/**
|
||||
@@ -142,7 +148,13 @@ export function ContextMenu({
|
||||
disableCreateFolder = false,
|
||||
}: ContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -153,6 +165,7 @@ export function ContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Navigation actions */}
|
||||
{showOpenInNewTab && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
@@ -163,6 +176,9 @@ export function ContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
|
||||
|
||||
{/* Edit and create actions */}
|
||||
{showRename && onRename && (
|
||||
<PopoverItem
|
||||
disabled={disableRename}
|
||||
@@ -196,6 +212,9 @@ export function ContextMenu({
|
||||
Create folder
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Copy and export actions */}
|
||||
{(showDuplicate || showExport) && <PopoverDivider />}
|
||||
{showDuplicate && onDuplicate && (
|
||||
<PopoverItem
|
||||
disabled={disableDuplicate}
|
||||
@@ -218,6 +237,9 @@ export function ContextMenu({
|
||||
Export
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
|
||||
@@ -180,10 +180,7 @@ export const PermissionsTable = ({
|
||||
{resendingInvitationIds &&
|
||||
user.invitationId &&
|
||||
resendingInvitationIds[user.invitationId] ? (
|
||||
<>
|
||||
<Loader2 className='h-[12px] w-[12px] animate-spin' />
|
||||
<span>Sending...</span>
|
||||
</>
|
||||
<span>Sending...</span>
|
||||
) : resentInvitationIds &&
|
||||
user.invitationId &&
|
||||
resentInvitationIds[user.invitationId] ? (
|
||||
|
||||
@@ -341,7 +341,7 @@ export function WorkspaceHeader({
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>
|
||||
{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}
|
||||
</p>
|
||||
@@ -364,7 +364,7 @@ export function WorkspaceHeader({
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>
|
||||
{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
|
||||
</p>
|
||||
|
||||
@@ -529,7 +529,7 @@ export function Sidebar() {
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -544,7 +544,7 @@ export function Sidebar() {
|
||||
<FolderPlus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -559,7 +559,7 @@ export function Sidebar() {
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -27,6 +27,7 @@ export type DocumentProcessingPayload = {
|
||||
export const processDocument = task({
|
||||
id: 'knowledge-process-document',
|
||||
maxDuration: env.KB_CONFIG_MAX_DURATION || 600,
|
||||
machine: 'large-1x', // 2 vCPU, 2GB RAM - needed for large PDF processing
|
||||
retry: {
|
||||
maxAttempts: env.KB_CONFIG_MAX_ATTEMPTS || 3,
|
||||
factor: env.KB_CONFIG_RETRY_FACTOR || 2,
|
||||
|
||||
@@ -321,7 +321,7 @@ describe('Blocks Module', () => {
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('router')
|
||||
expect(block?.name).toBe('Router')
|
||||
expect(block?.name).toBe('Router (Legacy)')
|
||||
expect(block?.category).toBe('blocks')
|
||||
expect(block?.authMode).toBe(AuthMode.ApiKey)
|
||||
})
|
||||
@@ -454,6 +454,7 @@ describe('Blocks Module', () => {
|
||||
'workflow-selector',
|
||||
'workflow-input-mapper',
|
||||
'text',
|
||||
'router-input',
|
||||
]
|
||||
|
||||
const blocks = getAllBlocks()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GrainIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
import { grainTriggerOptions } from '@/triggers/grain/utils'
|
||||
|
||||
export const GrainBlock: BlockConfig = {
|
||||
type: 'grain',
|
||||
@@ -207,13 +208,21 @@ export const GrainBlock: BlockConfig = {
|
||||
value: ['grain_delete_hook'],
|
||||
},
|
||||
},
|
||||
// Trigger SubBlocks
|
||||
...getTrigger('grain_recording_created').subBlocks,
|
||||
...getTrigger('grain_recording_updated').subBlocks,
|
||||
...getTrigger('grain_highlight_created').subBlocks,
|
||||
...getTrigger('grain_highlight_updated').subBlocks,
|
||||
...getTrigger('grain_story_created').subBlocks,
|
||||
...getTrigger('grain_webhook').subBlocks,
|
||||
{
|
||||
id: 'selectedTriggerId',
|
||||
title: 'Trigger Type',
|
||||
type: 'dropdown',
|
||||
mode: 'trigger',
|
||||
options: grainTriggerOptions,
|
||||
value: () => 'grain_webhook',
|
||||
required: true,
|
||||
},
|
||||
...getTrigger('grain_recording_created').subBlocks.slice(1),
|
||||
...getTrigger('grain_recording_updated').subBlocks.slice(1),
|
||||
...getTrigger('grain_highlight_created').subBlocks.slice(1),
|
||||
...getTrigger('grain_highlight_updated').subBlocks.slice(1),
|
||||
...getTrigger('grain_story_created').subBlocks.slice(1),
|
||||
...getTrigger('grain_webhook').subBlocks.slice(1),
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
|
||||
@@ -27,7 +27,7 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
// },
|
||||
{
|
||||
id: 'builderData',
|
||||
title: 'Paused Output',
|
||||
title: 'Display Data',
|
||||
type: 'response-format',
|
||||
// condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode
|
||||
description:
|
||||
@@ -35,7 +35,7 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
},
|
||||
{
|
||||
id: 'notification',
|
||||
title: 'Notification',
|
||||
title: 'Notification (Send URL)',
|
||||
type: 'tool-input',
|
||||
// condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode
|
||||
description: 'Configure notification tools to alert approvers (e.g., Slack, Email)',
|
||||
@@ -57,7 +57,7 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
// },
|
||||
{
|
||||
id: 'inputFormat',
|
||||
title: 'Resume Input',
|
||||
title: 'Resume Form',
|
||||
type: 'input-format',
|
||||
// condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode
|
||||
description: 'Define the fields the approver can fill in when resuming',
|
||||
@@ -157,6 +157,9 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
},
|
||||
outputs: {
|
||||
url: { type: 'string', description: 'Resume UI URL' },
|
||||
// apiUrl: { type: 'string', description: 'Resume API URL' }, // Commented out - not accessible as output
|
||||
resumeEndpoint: {
|
||||
type: 'string',
|
||||
description: 'Resume API endpoint URL for direct curl requests',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ interface TargetBlock {
|
||||
currentState?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the system prompt for the legacy router (block-based).
|
||||
*/
|
||||
export const generateRouterPrompt = (prompt: string, targetBlocks?: TargetBlock[]): string => {
|
||||
const basePrompt = `You are an intelligent routing agent responsible for directing workflow requests to the most appropriate block. Your task is to analyze the input and determine the single most suitable destination based on the request.
|
||||
|
||||
@@ -107,9 +110,88 @@ Example: "2acd9007-27e8-4510-a487-73d3b825e7c1"
|
||||
Remember: Your response must be ONLY the block ID - no additional text, formatting, or explanation.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the system prompt for the port-based router (v2).
|
||||
* Instead of selecting a block by ID, it selects a route by evaluating all route descriptions.
|
||||
*/
|
||||
export const generateRouterV2Prompt = (
|
||||
context: string,
|
||||
routes: Array<{ id: string; title: string; value: string }>
|
||||
): string => {
|
||||
const routesInfo = routes
|
||||
.map(
|
||||
(route, index) => `
|
||||
Route ${index + 1}:
|
||||
ID: ${route.id}
|
||||
Description: ${route.value || 'No description provided'}
|
||||
---`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
|
||||
|
||||
Available Routes:
|
||||
${routesInfo}
|
||||
|
||||
Context to analyze:
|
||||
${context}
|
||||
|
||||
Instructions:
|
||||
1. Carefully analyze the context against each route's description
|
||||
2. Select the route that best matches the context's intent and requirements
|
||||
3. Consider the semantic meaning, not just keyword matching
|
||||
4. If multiple routes could match, choose the most specific one
|
||||
|
||||
Response Format:
|
||||
Return ONLY the route ID as a single string, no punctuation, no explanation.
|
||||
Example: "route-abc123"
|
||||
|
||||
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get model options for both router versions.
|
||||
*/
|
||||
const getModelOptions = () => {
|
||||
const providersState = useProvidersStore.getState()
|
||||
const baseModels = providersState.providers.base.models
|
||||
const ollamaModels = providersState.providers.ollama.models
|
||||
const vllmModels = providersState.providers.vllm.models
|
||||
const openrouterModels = providersState.providers.openrouter.models
|
||||
const allModels = Array.from(
|
||||
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
|
||||
)
|
||||
|
||||
return allModels.map((model) => {
|
||||
const icon = getProviderIcon(model)
|
||||
return { label: model, id: model, ...(icon && { icon }) }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get API key condition for both router versions.
|
||||
*/
|
||||
const getApiKeyCondition = () => {
|
||||
return isHosted
|
||||
? {
|
||||
field: 'model',
|
||||
value: [...getHostedModels(), ...providers.vertex.models],
|
||||
not: true,
|
||||
}
|
||||
: () => ({
|
||||
field: 'model',
|
||||
value: [...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models],
|
||||
not: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Router Block (block-based routing).
|
||||
* Hidden from toolbar but still supported for existing workflows.
|
||||
*/
|
||||
export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
type: 'router',
|
||||
name: 'Router',
|
||||
name: 'Router (Legacy)',
|
||||
description: 'Route workflow',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
@@ -121,6 +203,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
category: 'blocks',
|
||||
bgColor: '#28C43F',
|
||||
icon: ConnectIcon,
|
||||
hideFromToolbar: true, // Hide legacy version from toolbar
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'prompt',
|
||||
@@ -136,21 +219,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
placeholder: 'Type or select a model...',
|
||||
required: true,
|
||||
defaultValue: 'claude-sonnet-4-5',
|
||||
options: () => {
|
||||
const providersState = useProvidersStore.getState()
|
||||
const baseModels = providersState.providers.base.models
|
||||
const ollamaModels = providersState.providers.ollama.models
|
||||
const vllmModels = providersState.providers.vllm.models
|
||||
const openrouterModels = providersState.providers.openrouter.models
|
||||
const allModels = Array.from(
|
||||
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
|
||||
)
|
||||
|
||||
return allModels.map((model) => {
|
||||
const icon = getProviderIcon(model)
|
||||
return { label: model, id: model, ...(icon && { icon }) }
|
||||
})
|
||||
},
|
||||
options: getModelOptions,
|
||||
},
|
||||
{
|
||||
id: 'vertexCredential',
|
||||
@@ -173,22 +242,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
// Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
|
||||
condition: isHosted
|
||||
? {
|
||||
field: 'model',
|
||||
value: [...getHostedModels(), ...providers.vertex.models],
|
||||
not: true, // Show for all models EXCEPT those listed
|
||||
}
|
||||
: () => ({
|
||||
field: 'model',
|
||||
value: [
|
||||
...getCurrentOllamaModels(),
|
||||
...getCurrentVLLMModels(),
|
||||
...providers.vertex.models,
|
||||
],
|
||||
not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
|
||||
}),
|
||||
condition: getApiKeyCondition(),
|
||||
},
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
@@ -303,3 +357,185 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Router V2 Block (port-based routing).
|
||||
* Uses route definitions with descriptions instead of downstream block names.
|
||||
*/
|
||||
interface RouterV2Response extends ToolResponse {
|
||||
output: {
|
||||
context: string
|
||||
model: string
|
||||
tokens?: {
|
||||
prompt?: number
|
||||
completion?: number
|
||||
total?: number
|
||||
}
|
||||
cost?: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
selectedRoute: string
|
||||
selectedPath: {
|
||||
blockId: string
|
||||
blockType: string
|
||||
blockTitle: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const RouterV2Block: BlockConfig<RouterV2Response> = {
|
||||
type: 'router_v2',
|
||||
name: 'Router',
|
||||
description: 'Route workflow based on context',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Intelligently route workflow execution to different paths based on context analysis. Define multiple routes with descriptions, and an LLM will determine which route to take based on the provided context.',
|
||||
bestPractices: `
|
||||
- Write clear, specific descriptions for each route
|
||||
- The context field should contain all relevant information for routing decisions
|
||||
- Route descriptions should be mutually exclusive when possible
|
||||
- Use descriptive route names to make the workflow readable
|
||||
`,
|
||||
category: 'blocks',
|
||||
bgColor: '#28C43F',
|
||||
icon: ConnectIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'context',
|
||||
title: 'Context',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter the context to analyze for routing...',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'routes',
|
||||
type: 'router-input',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'combobox',
|
||||
placeholder: 'Type or select a model...',
|
||||
required: true,
|
||||
defaultValue: 'claude-sonnet-4-5',
|
||||
options: getModelOptions,
|
||||
},
|
||||
{
|
||||
id: 'vertexCredential',
|
||||
title: 'Google Cloud Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'vertex-ai',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
placeholder: 'Select Google Cloud account',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: getApiKeyCondition(),
|
||||
},
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
title: 'Azure OpenAI Endpoint',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'https://your-resource.openai.azure.com',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'azureApiVersion',
|
||||
title: 'Azure API Version',
|
||||
type: 'short-input',
|
||||
placeholder: '2024-07-01-preview',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexProject',
|
||||
title: 'Vertex AI Project',
|
||||
type: 'short-input',
|
||||
placeholder: 'your-gcp-project-id',
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexLocation',
|
||||
title: 'Vertex AI Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-central1',
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'openai_chat',
|
||||
'anthropic_chat',
|
||||
'google_chat',
|
||||
'xai_chat',
|
||||
'deepseek_chat',
|
||||
'deepseek_reasoner',
|
||||
],
|
||||
config: {
|
||||
tool: (params: Record<string, any>) => {
|
||||
const model = params.model || 'gpt-4o'
|
||||
if (!model) {
|
||||
throw new Error('No model selected')
|
||||
}
|
||||
const tool = getAllModelProviders()[model as ProviderId]
|
||||
if (!tool) {
|
||||
throw new Error(`Invalid model selected: ${model}`)
|
||||
}
|
||||
return tool
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
context: { type: 'string', description: 'Context for routing decision' },
|
||||
routes: { type: 'json', description: 'Route definitions with descriptions' },
|
||||
model: { type: 'string', description: 'AI model to use' },
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
vertexCredential: {
|
||||
type: 'string',
|
||||
description: 'Google Cloud OAuth credential ID for Vertex AI',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
context: { type: 'string', description: 'Context used for routing' },
|
||||
model: { type: 'string', description: 'Model used' },
|
||||
tokens: { type: 'json', description: 'Token usage' },
|
||||
cost: { type: 'json', description: 'Cost information' },
|
||||
selectedRoute: { type: 'string', description: 'Selected route ID' },
|
||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -79,6 +79,16 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
value: ['query', 'get_row', 'insert', 'update', 'delete', 'upsert', 'count', 'text_search'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
title: 'Select Columns',
|
||||
type: 'short-input',
|
||||
placeholder: '* (all columns) or id,name,email',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['query', 'get_row'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'Service Role Secret',
|
||||
@@ -1044,6 +1054,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
|
||||
projectId: { type: 'string', description: 'Supabase project identifier' },
|
||||
table: { type: 'string', description: 'Database table name' },
|
||||
schema: { type: 'string', description: 'Database schema (default: public)' },
|
||||
select: { type: 'string', description: 'Columns to return (comma-separated, defaults to *)' },
|
||||
apiKey: { type: 'string', description: 'Service role secret key' },
|
||||
// Data for insert/update operations
|
||||
data: { type: 'json', description: 'Row data' },
|
||||
|
||||
86
apps/sim/blocks/blocks/webhook_request.ts
Normal file
86
apps/sim/blocks/blocks/webhook_request.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { WebhookIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { RequestResponse } from '@/tools/http/types'
|
||||
|
||||
export const WebhookRequestBlock: BlockConfig<RequestResponse> = {
|
||||
type: 'webhook_request',
|
||||
name: 'Webhook',
|
||||
description: 'Send a webhook request',
|
||||
longDescription:
|
||||
'Send an HTTP POST request to a webhook URL with automatic webhook headers. Optionally sign the payload with HMAC-SHA256 for secure webhook delivery.',
|
||||
docsLink: 'https://docs.sim.ai/blocks/webhook',
|
||||
category: 'blocks',
|
||||
bgColor: '#10B981',
|
||||
icon: WebhookIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'url',
|
||||
title: 'Webhook URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://example.com/webhook',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'body',
|
||||
title: 'Payload',
|
||||
type: 'code',
|
||||
placeholder: 'Enter JSON payload...',
|
||||
language: 'json',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert JSON programmer.
|
||||
Generate ONLY the raw JSON object based on the user's request.
|
||||
The output MUST be a single, valid JSON object, starting with { and ending with }.
|
||||
|
||||
Current payload: {context}
|
||||
|
||||
Do not include any explanations, markdown formatting, or other text outside the JSON object.
|
||||
|
||||
You have access to the following variables you can use to generate the JSON payload:
|
||||
- Use angle brackets for workflow variables, e.g., '<blockName.output>'.
|
||||
- Use double curly braces for environment variables, e.g., '{{ENV_VAR_NAME}}'.
|
||||
|
||||
Example:
|
||||
{
|
||||
"event": "workflow.completed",
|
||||
"data": {
|
||||
"result": "<agent.content>",
|
||||
"timestamp": "<function.result>"
|
||||
}
|
||||
}`,
|
||||
placeholder: 'Describe the webhook payload you need...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'secret',
|
||||
title: 'Signing Secret',
|
||||
type: 'short-input',
|
||||
placeholder: 'Optional: Secret for HMAC signature',
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
},
|
||||
{
|
||||
id: 'headers',
|
||||
title: 'Additional Headers',
|
||||
type: 'table',
|
||||
columns: ['Key', 'Value'],
|
||||
description: 'Optional custom headers to include with the webhook request',
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['webhook_request'],
|
||||
},
|
||||
inputs: {
|
||||
url: { type: 'string', description: 'Webhook URL to send the request to' },
|
||||
body: { type: 'json', description: 'JSON payload to send' },
|
||||
secret: { type: 'string', description: 'Optional secret for HMAC-SHA256 signature' },
|
||||
headers: { type: 'json', description: 'Optional additional headers' },
|
||||
},
|
||||
outputs: {
|
||||
data: { type: 'json', description: 'Response data from the webhook endpoint' },
|
||||
status: { type: 'number', description: 'HTTP status code' },
|
||||
headers: { type: 'json', description: 'Response headers' },
|
||||
},
|
||||
}
|
||||
@@ -92,7 +92,7 @@ import { RDSBlock } from '@/blocks/blocks/rds'
|
||||
import { RedditBlock } from '@/blocks/blocks/reddit'
|
||||
import { ResendBlock } from '@/blocks/blocks/resend'
|
||||
import { ResponseBlock } from '@/blocks/blocks/response'
|
||||
import { RouterBlock } from '@/blocks/blocks/router'
|
||||
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
|
||||
import { RssBlock } from '@/blocks/blocks/rss'
|
||||
import { S3Block } from '@/blocks/blocks/s3'
|
||||
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
|
||||
@@ -131,6 +131,7 @@ import { WaitBlock } from '@/blocks/blocks/wait'
|
||||
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
|
||||
import { WebflowBlock } from '@/blocks/blocks/webflow'
|
||||
import { WebhookBlock } from '@/blocks/blocks/webhook'
|
||||
import { WebhookRequestBlock } from '@/blocks/blocks/webhook_request'
|
||||
import { WhatsAppBlock } from '@/blocks/blocks/whatsapp'
|
||||
import { WikipediaBlock } from '@/blocks/blocks/wikipedia'
|
||||
import { WordPressBlock } from '@/blocks/blocks/wordpress'
|
||||
@@ -243,6 +244,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
response: ResponseBlock,
|
||||
rss: RssBlock,
|
||||
router: RouterBlock,
|
||||
router_v2: RouterV2Block,
|
||||
s3: S3Block,
|
||||
salesforce: SalesforceBlock,
|
||||
schedule: ScheduleBlock,
|
||||
@@ -280,6 +282,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
wealthbox: WealthboxBlock,
|
||||
webflow: WebflowBlock,
|
||||
webhook: WebhookBlock,
|
||||
webhook_request: WebhookRequestBlock,
|
||||
whatsapp: WhatsAppBlock,
|
||||
wikipedia: WikipediaBlock,
|
||||
wordpress: WordPressBlock,
|
||||
|
||||
@@ -78,6 +78,7 @@ export type SubBlockType =
|
||||
| 'workflow-selector' // Workflow selector for agent tools
|
||||
| 'workflow-input-mapper' // Dynamic workflow input mapper based on selected workflow
|
||||
| 'text' // Read-only text display
|
||||
| 'router-input' // Router route definitions with descriptions
|
||||
|
||||
/**
|
||||
* Selector types that require display name hydration
|
||||
@@ -217,6 +218,7 @@ export interface SubBlockConfig {
|
||||
hideFromPreview?: boolean // Hide this subblock from the workflow block preview
|
||||
requiresFeature?: string // Environment variable name that must be truthy for this subblock to be visible
|
||||
description?: string
|
||||
tooltip?: string // Tooltip text displayed via info icon next to the title
|
||||
value?: (params: Record<string, any>) => string
|
||||
grouped?: boolean
|
||||
scrollable?: boolean
|
||||
@@ -287,11 +289,19 @@ export interface SubBlockConfig {
|
||||
useWebhookUrl?: boolean
|
||||
// Trigger-save specific: The trigger ID for validation and saving
|
||||
triggerId?: string
|
||||
// Dropdown specific: Function to fetch options dynamically (for multi-select or single-select)
|
||||
// Dropdown/Combobox: Function to fetch options dynamically
|
||||
// Works with both 'dropdown' (select-only) and 'combobox' (editable with expression support)
|
||||
fetchOptions?: (
|
||||
blockId: string,
|
||||
subBlockId: string
|
||||
) => Promise<Array<{ label: string; id: string }>>
|
||||
// Dropdown/Combobox: Function to fetch a single option's label by ID (for hydration)
|
||||
// Called when component mounts with a stored value to display the correct label before options load
|
||||
fetchOptionById?: (
|
||||
blockId: string,
|
||||
subBlockId: string,
|
||||
optionId: string
|
||||
) => Promise<{ label: string; id: string } | null>
|
||||
}
|
||||
|
||||
export interface BlockConfig<T extends ToolResponse = ToolResponse> {
|
||||
|
||||
@@ -57,6 +57,8 @@ export {
|
||||
type PopoverBackButtonProps,
|
||||
PopoverContent,
|
||||
type PopoverContentProps,
|
||||
PopoverDivider,
|
||||
type PopoverDividerProps,
|
||||
PopoverFolder,
|
||||
type PopoverFolderProps,
|
||||
PopoverItem,
|
||||
|
||||
@@ -55,53 +55,100 @@ import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
type PopoverSize = 'sm' | 'md'
|
||||
|
||||
/**
|
||||
* Shared base styles for all popover interactive items.
|
||||
* Ensures consistent styling across items, folders, and back button.
|
||||
*/
|
||||
const POPOVER_ITEM_BASE_CLASSES =
|
||||
'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
/**
|
||||
* Size-specific styles for popover items.
|
||||
* SM: 11px text, 22px height
|
||||
* MD: 13px text, 26px height
|
||||
*/
|
||||
const POPOVER_ITEM_SIZE_CLASSES: Record<PopoverSize, string> = {
|
||||
sm: 'h-[22px] text-[11px]',
|
||||
md: 'h-[26px] text-[13px]',
|
||||
}
|
||||
|
||||
/**
|
||||
* Size-specific icon classes for popover items.
|
||||
*/
|
||||
const POPOVER_ICON_SIZE_CLASSES: Record<PopoverSize, string> = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-3.5 w-3.5',
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant-specific active state styles for popover items.
|
||||
*/
|
||||
const POPOVER_ITEM_ACTIVE_CLASSES = {
|
||||
secondary: 'bg-[var(--brand-secondary)] text-[var(--bg)] [&_svg]:text-[var(--bg)]',
|
||||
default:
|
||||
'bg-[var(--surface-7)] dark:bg-[var(--surface-5)] text-[var(--text-primary)] [&_svg]:text-[var(--text-primary)]',
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant-specific hover state styles for popover items.
|
||||
*/
|
||||
const POPOVER_ITEM_HOVER_CLASSES = {
|
||||
secondary:
|
||||
'hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] hover:[&_svg]:text-[var(--bg)]',
|
||||
default:
|
||||
'hover:bg-[var(--surface-7)] dark:hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)] hover:[&_svg]:text-[var(--text-primary)]',
|
||||
}
|
||||
|
||||
type PopoverColorScheme = 'default' | 'inverted'
|
||||
type PopoverVariant = 'default' | 'secondary'
|
||||
|
||||
/**
|
||||
* Style constants for popover components.
|
||||
* Organized by component type and property.
|
||||
*/
|
||||
const STYLES = {
|
||||
/** Base classes shared by all interactive items */
|
||||
itemBase:
|
||||
'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
|
||||
/** Content container */
|
||||
content: 'px-[6px] py-[6px] rounded-[6px]',
|
||||
|
||||
/** Size variants */
|
||||
size: {
|
||||
sm: { item: 'h-[22px] text-[11px]', icon: 'h-3 w-3', section: 'px-[6px] py-[4px] text-[11px]' },
|
||||
md: {
|
||||
item: 'h-[26px] text-[13px]',
|
||||
icon: 'h-3.5 w-3.5',
|
||||
section: 'px-[6px] py-[4px] text-[13px]',
|
||||
},
|
||||
} satisfies Record<PopoverSize, { item: string; icon: string; section: string }>,
|
||||
|
||||
/** Color scheme variants */
|
||||
colorScheme: {
|
||||
default: {
|
||||
text: 'text-[var(--text-primary)]',
|
||||
section: 'text-[var(--text-tertiary)]',
|
||||
search: 'text-[var(--text-muted)]',
|
||||
searchInput: 'text-[var(--text-primary)] placeholder:text-[var(--text-muted)]',
|
||||
content: 'bg-[var(--surface-5)] text-foreground dark:bg-[var(--surface-3)]',
|
||||
divider: 'border-[var(--border-1)]',
|
||||
},
|
||||
inverted: {
|
||||
text: 'text-white dark:text-[var(--text-primary)]',
|
||||
section: 'text-[var(--text-muted-inverse)]',
|
||||
search: 'text-[var(--text-muted-inverse)] dark:text-[var(--text-muted)]',
|
||||
searchInput:
|
||||
'text-white placeholder:text-[var(--text-muted-inverse)] dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-muted)]',
|
||||
content: 'bg-[#1b1b1b] text-white dark:bg-[var(--surface-3)] dark:text-foreground',
|
||||
divider: 'border-[#363636] dark:border-[var(--border-1)]',
|
||||
},
|
||||
} satisfies Record<
|
||||
PopoverColorScheme,
|
||||
{
|
||||
text: string
|
||||
section: string
|
||||
search: string
|
||||
searchInput: string
|
||||
content: string
|
||||
divider: string
|
||||
}
|
||||
>,
|
||||
|
||||
/** Interactive state styles: default, secondary (brand), inverted (dark bg in light mode) */
|
||||
states: {
|
||||
default: {
|
||||
active: 'bg-[var(--border-1)] text-[var(--text-primary)] [&_svg]:text-[var(--text-primary)]',
|
||||
hover:
|
||||
'hover:bg-[var(--border-1)] hover:text-[var(--text-primary)] hover:[&_svg]:text-[var(--text-primary)]',
|
||||
},
|
||||
secondary: {
|
||||
active: 'bg-[var(--brand-secondary)] text-white [&_svg]:text-white',
|
||||
hover: 'hover:bg-[var(--brand-secondary)] hover:text-white hover:[&_svg]:text-white',
|
||||
},
|
||||
inverted: {
|
||||
active:
|
||||
'bg-[#363636] text-white [&_svg]:text-white dark:bg-[var(--surface-5)] dark:text-[var(--text-primary)] dark:[&_svg]:text-[var(--text-primary)]',
|
||||
hover:
|
||||
'hover:bg-[#363636] hover:text-white hover:[&_svg]:text-white dark:hover:bg-[var(--surface-5)] dark:hover:text-[var(--text-primary)] dark:hover:[&_svg]:text-[var(--text-primary)]',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Gets the active/hover classes for a popover item.
|
||||
* Uses variant for secondary, otherwise colorScheme determines default vs inverted.
|
||||
*/
|
||||
function getItemStateClasses(
|
||||
variant: PopoverVariant,
|
||||
colorScheme: PopoverColorScheme,
|
||||
isActive: boolean
|
||||
): string {
|
||||
const state = isActive ? 'active' : 'hover'
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return STYLES.states.secondary[state]
|
||||
}
|
||||
|
||||
return colorScheme === 'inverted' ? STYLES.states.inverted[state] : STYLES.states.default[state]
|
||||
}
|
||||
|
||||
interface PopoverContextValue {
|
||||
openFolder: (
|
||||
id: string,
|
||||
@@ -116,6 +163,7 @@ interface PopoverContextValue {
|
||||
onFolderSelect: (() => void) | null
|
||||
variant: PopoverVariant
|
||||
size: PopoverSize
|
||||
colorScheme: PopoverColorScheme
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
}
|
||||
@@ -143,23 +191,23 @@ export interface PopoverProps extends PopoverPrimitive.PopoverProps {
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: PopoverSize
|
||||
/**
|
||||
* Color scheme for the popover
|
||||
* - default: light background in light mode, dark in dark mode
|
||||
* - inverted: dark background (#1b1b1b) in light mode, matches tooltip styling
|
||||
* @default 'default'
|
||||
*/
|
||||
colorScheme?: PopoverColorScheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Root popover component. Manages open state and folder navigation context.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Popover open={open} onOpenChange={setOpen} variant="default" size="md">
|
||||
* <PopoverAnchor>...</PopoverAnchor>
|
||||
* <PopoverContent>...</PopoverContent>
|
||||
* </Popover>
|
||||
* ```
|
||||
*/
|
||||
const Popover: React.FC<PopoverProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
colorScheme = 'default',
|
||||
...props
|
||||
}) => {
|
||||
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
|
||||
@@ -185,7 +233,7 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
setOnFolderSelect(null)
|
||||
}, [])
|
||||
|
||||
const contextValue: PopoverContextValue = React.useMemo(
|
||||
const contextValue = React.useMemo<PopoverContextValue>(
|
||||
() => ({
|
||||
openFolder,
|
||||
closeFolder,
|
||||
@@ -195,6 +243,7 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
onFolderSelect,
|
||||
variant,
|
||||
size,
|
||||
colorScheme,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}),
|
||||
@@ -206,6 +255,7 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
onFolderSelect,
|
||||
variant,
|
||||
size,
|
||||
colorScheme,
|
||||
searchQuery,
|
||||
]
|
||||
)
|
||||
@@ -222,13 +272,6 @@ Popover.displayName = 'Popover'
|
||||
/**
|
||||
* Trigger element that opens/closes the popover when clicked.
|
||||
* Use asChild to render as a custom component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PopoverTrigger asChild>
|
||||
* <Button>Open Menu</Button>
|
||||
* </PopoverTrigger>
|
||||
* ```
|
||||
*/
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
@@ -244,74 +287,48 @@ export interface PopoverContentProps
|
||||
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding'
|
||||
> {
|
||||
/**
|
||||
* When true, renders the popover content inline instead of in a portal.
|
||||
* Useful when used inside other portalled components (e.g. dialogs)
|
||||
* where additional portals can interfere with scroll locking behavior.
|
||||
* Renders content inline instead of in a portal.
|
||||
* Useful inside dialogs where portals interfere with scroll locking.
|
||||
* @default false
|
||||
*/
|
||||
disablePortal?: boolean
|
||||
/**
|
||||
* Maximum height for the popover content in pixels
|
||||
*/
|
||||
/** Maximum height in pixels */
|
||||
maxHeight?: number
|
||||
/**
|
||||
* Maximum width for the popover content in pixels.
|
||||
* When provided, Popover will also enable default truncation for inner text and section headers.
|
||||
*/
|
||||
/** Maximum width in pixels. Enables text truncation when set. */
|
||||
maxWidth?: number
|
||||
/**
|
||||
* Minimum width for the popover content in pixels
|
||||
*/
|
||||
/** Minimum width in pixels */
|
||||
minWidth?: number
|
||||
/**
|
||||
* Preferred side to display the popover
|
||||
* Preferred side to display
|
||||
* @default 'bottom'
|
||||
*/
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
/**
|
||||
* Alignment of the popover relative to anchor
|
||||
* Alignment relative to anchor
|
||||
* @default 'start'
|
||||
*/
|
||||
align?: 'start' | 'center' | 'end'
|
||||
/**
|
||||
* Offset from the anchor in pixels.
|
||||
* Defaults to 22px for top side (to avoid covering cursor) and 10px for other sides.
|
||||
*/
|
||||
/** Offset from anchor. Defaults to 20px for top, 14px for other sides. */
|
||||
sideOffset?: number
|
||||
/**
|
||||
* Padding from viewport edges in pixels
|
||||
* Padding from viewport edges
|
||||
* @default 8
|
||||
*/
|
||||
collisionPadding?: number
|
||||
/**
|
||||
* When true, adds a border to the popover content
|
||||
* Adds border to content
|
||||
* @default false
|
||||
*/
|
||||
border?: boolean
|
||||
/**
|
||||
* When true, the popover will flip to avoid collisions with viewport edges
|
||||
* Flip to avoid viewport collisions
|
||||
* @default true
|
||||
*/
|
||||
avoidCollisions?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared styles for popover content container.
|
||||
* Both sizes use same padding and 6px border radius.
|
||||
*/
|
||||
const POPOVER_CONTENT_CLASSES = 'px-[6px] py-[6px] rounded-[6px]'
|
||||
|
||||
/**
|
||||
* Popover content component with automatic positioning and collision detection.
|
||||
* Wraps children in a styled container with scrollable area.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PopoverContent maxHeight={300}>
|
||||
* <PopoverItem>Item 1</PopoverItem>
|
||||
* <PopoverItem>Item 2</PopoverItem>
|
||||
* </PopoverContent>
|
||||
* ```
|
||||
* Popover content with automatic positioning and collision detection.
|
||||
*/
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
@@ -340,13 +357,10 @@ const PopoverContent = React.forwardRef<
|
||||
) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
const size = context?.size || 'md'
|
||||
const colorScheme = context?.colorScheme || 'default'
|
||||
|
||||
// Smart default offset: larger offset when rendering above to avoid covering cursor
|
||||
const effectiveSideOffset = sideOffset ?? (side === 'top' ? 20 : 14)
|
||||
|
||||
// Detect explicit width constraints provided by the consumer.
|
||||
// When present, we enable default text truncation behavior for inner flexible items,
|
||||
// so callers don't need to manually pass 'truncate' to every label.
|
||||
const hasUserWidthConstraint =
|
||||
maxWidth !== undefined ||
|
||||
minWidth !== undefined ||
|
||||
@@ -359,29 +373,21 @@ const PopoverContent = React.forwardRef<
|
||||
if (!container) return
|
||||
|
||||
const { scrollHeight, clientHeight, scrollTop } = container
|
||||
if (scrollHeight <= clientHeight) {
|
||||
return
|
||||
}
|
||||
if (scrollHeight <= clientHeight) return
|
||||
|
||||
const deltaY = event.deltaY
|
||||
const isScrollingDown = deltaY > 0
|
||||
const isAtTop = scrollTop === 0
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight
|
||||
|
||||
// If we're at the boundary and user keeps scrolling in that direction,
|
||||
// let the event bubble so parent scroll containers can handle it.
|
||||
if ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) {
|
||||
return
|
||||
}
|
||||
if ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) return
|
||||
|
||||
// Otherwise, consume the wheel event and manually scroll the popover content.
|
||||
event.preventDefault()
|
||||
container.scrollTop += deltaY
|
||||
}
|
||||
|
||||
const handleOpenAutoFocus = React.useCallback(
|
||||
(e: Event) => {
|
||||
// Always prevent auto-focus to avoid flickering from focus-triggered repositioning
|
||||
e.preventDefault()
|
||||
onOpenAutoFocus?.(e)
|
||||
},
|
||||
@@ -390,7 +396,6 @@ const PopoverContent = React.forwardRef<
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => {
|
||||
// Always prevent auto-focus to avoid flickering from focus-triggered repositioning
|
||||
e.preventDefault()
|
||||
onCloseAutoFocus?.(e)
|
||||
},
|
||||
@@ -412,11 +417,9 @@ const PopoverContent = React.forwardRef<
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
{...restProps}
|
||||
className={cn(
|
||||
// will-change-transform creates a new GPU compositing layer to prevent paint flickering
|
||||
'z-[10000200] flex flex-col overflow-auto bg-[var(--surface-5)] text-foreground outline-none will-change-transform dark:bg-[var(--surface-3)]',
|
||||
POPOVER_CONTENT_CLASSES,
|
||||
// If width is constrained by the caller (prop or style), ensure inner flexible text truncates by default,
|
||||
// and also truncate section headers.
|
||||
'z-[10000200] flex flex-col overflow-auto outline-none will-change-transform',
|
||||
STYLES.colorScheme[colorScheme].content,
|
||||
STYLES.content,
|
||||
hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate',
|
||||
border && 'border border-[var(--border-1)]',
|
||||
className
|
||||
@@ -424,7 +427,6 @@ const PopoverContent = React.forwardRef<
|
||||
style={{
|
||||
maxHeight: `${maxHeight || 400}px`,
|
||||
maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
|
||||
// Only enforce default min width when the user hasn't set width constraints
|
||||
minWidth:
|
||||
minWidth !== undefined
|
||||
? `${minWidth}px`
|
||||
@@ -440,9 +442,7 @@ const PopoverContent = React.forwardRef<
|
||||
</PopoverPrimitive.Content>
|
||||
)
|
||||
|
||||
if (disablePortal) {
|
||||
return content
|
||||
}
|
||||
if (disablePortal) return content
|
||||
|
||||
return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
|
||||
}
|
||||
@@ -453,83 +453,58 @@ PopoverContent.displayName = 'PopoverContent'
|
||||
export interface PopoverScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
/**
|
||||
* Scrollable area container for popover items.
|
||||
* Use this to wrap items that should scroll within the popover.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PopoverContent>
|
||||
* <PopoverScrollArea>
|
||||
* <PopoverItem>Item 1</PopoverItem>
|
||||
* <PopoverItem>Item 2</PopoverItem>
|
||||
* </PopoverScrollArea>
|
||||
* </PopoverContent>
|
||||
* ```
|
||||
* Scrollable container for popover items.
|
||||
*/
|
||||
const PopoverScrollArea = React.forwardRef<HTMLDivElement, PopoverScrollAreaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 overflow-auto overscroll-contain',
|
||||
// Add margin to wrapper divs containing sections (not individual items)
|
||||
'[&>div:has([data-popover-section]):not(:first-child)]:mt-[6px]',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 overflow-auto overscroll-contain',
|
||||
'[&>div:has([data-popover-section]):not(:first-child)]:mt-[6px]',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
PopoverScrollArea.displayName = 'PopoverScrollArea'
|
||||
|
||||
export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Whether this item is currently active/selected
|
||||
* Whether this item has active/highlighted background styling.
|
||||
* Use for keyboard navigation focus or persistent highlight states.
|
||||
*/
|
||||
active?: boolean
|
||||
/**
|
||||
* If true, this item will only show when not inside any folder
|
||||
*/
|
||||
/** Only show when not inside any folder */
|
||||
rootOnly?: boolean
|
||||
/**
|
||||
* Whether this item is disabled
|
||||
*/
|
||||
/** Whether this item is disabled */
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Whether to show a checkmark when active
|
||||
* Show a checkmark to indicate selection/checked state.
|
||||
* Unlike `active`, this only shows the checkmark without background highlight,
|
||||
* following the pattern where hover provides interaction feedback
|
||||
* and checkmarks indicate current value.
|
||||
* @default false
|
||||
*/
|
||||
showCheck?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover item component for individual items within a popover.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PopoverItem active={isActive} disabled={isDisabled} onClick={() => handleClick()}>
|
||||
* <Icon className="h-3.5 w-3.5" />
|
||||
* <span>Item label</span>
|
||||
* </PopoverItem>
|
||||
* ```
|
||||
* Individual popover item with hover and active states.
|
||||
*/
|
||||
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
(
|
||||
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
|
||||
ref
|
||||
) => {
|
||||
// Try to get context - if not available, we're outside Popover (shouldn't happen)
|
||||
const context = React.useContext(PopoverContext)
|
||||
const variant = context?.variant || 'default'
|
||||
const size = context?.size || 'md'
|
||||
const colorScheme = context?.colorScheme || 'default'
|
||||
|
||||
// If rootOnly is true and we're in a folder, don't render
|
||||
if (rootOnly && context?.isInFolder) {
|
||||
return null
|
||||
}
|
||||
if (rootOnly && context?.isInFolder) return null
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (disabled) {
|
||||
@@ -542,9 +517,10 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
POPOVER_ITEM_BASE_CLASSES,
|
||||
POPOVER_ITEM_SIZE_CLASSES[size],
|
||||
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
|
||||
STYLES.itemBase,
|
||||
STYLES.colorScheme[colorScheme].text,
|
||||
STYLES.size[size].item,
|
||||
getItemStateClasses(variant, colorScheme, !!active),
|
||||
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
@@ -556,9 +532,7 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCheck && active && (
|
||||
<Check className={cn('ml-auto', POPOVER_ICON_SIZE_CLASSES[size])} />
|
||||
)}
|
||||
{showCheck && <Check className={cn('ml-auto', STYLES.size[size].icon)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -567,46 +541,27 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
PopoverItem.displayName = 'PopoverItem'
|
||||
|
||||
export interface PopoverSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* If true, this section will only show when not inside any folder
|
||||
*/
|
||||
/** Only show when not inside any folder */
|
||||
rootOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Size-specific styles for popover section headers.
|
||||
* Shared: 6px padding, 4px vertical padding
|
||||
*/
|
||||
const POPOVER_SECTION_SIZE_CLASSES: Record<PopoverSize, string> = {
|
||||
sm: 'px-[6px] py-[4px] text-[11px]',
|
||||
md: 'px-[6px] py-[4px] text-[13px]',
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover section header component for grouping items with a title.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PopoverSection>
|
||||
* Section Title
|
||||
* </PopoverSection>
|
||||
* ```
|
||||
* Section header for grouping popover items.
|
||||
*/
|
||||
const PopoverSection = React.forwardRef<HTMLDivElement, PopoverSectionProps>(
|
||||
({ className, rootOnly, ...props }, ref) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
const size = context?.size || 'md'
|
||||
const colorScheme = context?.colorScheme || 'default'
|
||||
|
||||
// If rootOnly is true and we're in a folder, don't render
|
||||
if (rootOnly && context?.isInFolder) {
|
||||
return null
|
||||
}
|
||||
if (rootOnly && context?.isInFolder) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-[6px] min-w-0 font-base text-[var(--text-tertiary)] first:mt-0 first:pt-0 dark:text-[var(--text-tertiary)]',
|
||||
POPOVER_SECTION_SIZE_CLASSES[size],
|
||||
'mt-[6px] min-w-0 font-base first:mt-0 first:pt-0',
|
||||
STYLES.colorScheme[colorScheme].section,
|
||||
STYLES.size[size].section,
|
||||
className
|
||||
)}
|
||||
data-popover-section=''
|
||||
@@ -620,76 +575,46 @@ const PopoverSection = React.forwardRef<HTMLDivElement, PopoverSectionProps>(
|
||||
PopoverSection.displayName = 'PopoverSection'
|
||||
|
||||
export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
/**
|
||||
* Unique identifier for the folder
|
||||
*/
|
||||
/** Unique folder identifier */
|
||||
id: string
|
||||
/**
|
||||
* Display title for the folder
|
||||
*/
|
||||
/** Display title */
|
||||
title: string
|
||||
/**
|
||||
* Icon to display before the title
|
||||
*/
|
||||
/** Icon before title */
|
||||
icon?: React.ReactNode
|
||||
/**
|
||||
* Function to call when folder is opened (for lazy loading)
|
||||
*/
|
||||
/** Callback when folder opens (for lazy loading) */
|
||||
onOpen?: () => void | Promise<void>
|
||||
/**
|
||||
* Function to call when the folder title is selected (from within the folder view)
|
||||
*/
|
||||
/** Callback when folder title is selected from within folder view */
|
||||
onSelect?: () => void
|
||||
/**
|
||||
* Children to render when folder is open
|
||||
*/
|
||||
/** Folder contents */
|
||||
children?: React.ReactNode
|
||||
/**
|
||||
* Whether this item is currently active/selected
|
||||
*/
|
||||
/** Whether currently active/selected */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover folder component that expands to show nested content.
|
||||
* Automatically handles navigation and back button rendering.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PopoverFolder id="workflows" title="Workflows" icon={<Icon />}>
|
||||
* <PopoverItem>Workflow 1</PopoverItem>
|
||||
* <PopoverItem>Workflow 2</PopoverItem>
|
||||
* </PopoverFolder>
|
||||
* ```
|
||||
* Expandable folder that shows nested content.
|
||||
*/
|
||||
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
|
||||
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
|
||||
const { openFolder, currentFolder, isInFolder, variant, size } = usePopoverContext()
|
||||
const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
|
||||
usePopoverContext()
|
||||
|
||||
// Don't render if we're in a different folder
|
||||
if (isInFolder && currentFolder !== id) {
|
||||
return null
|
||||
}
|
||||
if (isInFolder && currentFolder !== id) return null
|
||||
if (currentFolder === id) return <>{children}</>
|
||||
|
||||
// If we're in this folder, render its children
|
||||
if (currentFolder === id) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Handle click anywhere on folder item
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
openFolder(id, title, onOpen, onSelect)
|
||||
}
|
||||
|
||||
// Otherwise, render as a clickable folder item
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
POPOVER_ITEM_BASE_CLASSES,
|
||||
POPOVER_ITEM_SIZE_CLASSES[size],
|
||||
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
|
||||
STYLES.itemBase,
|
||||
STYLES.colorScheme[colorScheme].text,
|
||||
STYLES.size[size].item,
|
||||
getItemStateClasses(variant, colorScheme, !!active),
|
||||
className
|
||||
)}
|
||||
role='menuitem'
|
||||
@@ -700,7 +625,7 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
|
||||
>
|
||||
{icon}
|
||||
<span className='flex-1'>{title}</span>
|
||||
<ChevronRight className={POPOVER_ICON_SIZE_CLASSES[size]} />
|
||||
<ChevronRight className={STYLES.size[size].icon} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -709,42 +634,23 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
|
||||
PopoverFolder.displayName = 'PopoverFolder'
|
||||
|
||||
export interface PopoverBackButtonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Ref callback for the folder title element (when selectable)
|
||||
*/
|
||||
/** Ref callback for folder title element */
|
||||
folderTitleRef?: (el: HTMLElement | null) => void
|
||||
/**
|
||||
* Whether the folder title is currently active/selected
|
||||
*/
|
||||
/** Whether folder title is active/selected */
|
||||
folderTitleActive?: boolean
|
||||
/**
|
||||
* Callback when mouse enters the folder title
|
||||
*/
|
||||
/** Callback on folder title mouse enter */
|
||||
onFolderTitleMouseEnter?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Back button component that appears when inside a folder.
|
||||
* Automatically hidden when at root level.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Popover>
|
||||
* <PopoverBackButton />
|
||||
* <PopoverContent>
|
||||
* // content
|
||||
* </PopoverContent>
|
||||
* </Popover>
|
||||
* ```
|
||||
* Back button shown inside folders. Hidden at root level.
|
||||
*/
|
||||
const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProps>(
|
||||
({ className, folderTitleRef, folderTitleActive, onFolderTitleMouseEnter, ...props }, ref) => {
|
||||
const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size } =
|
||||
const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size, colorScheme } =
|
||||
usePopoverContext()
|
||||
|
||||
if (!isInFolder) {
|
||||
return null
|
||||
}
|
||||
if (!isInFolder) return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
@@ -752,28 +658,27 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer',
|
||||
POPOVER_ITEM_BASE_CLASSES,
|
||||
POPOVER_ITEM_SIZE_CLASSES[size],
|
||||
POPOVER_ITEM_HOVER_CLASSES[variant],
|
||||
STYLES.itemBase,
|
||||
STYLES.colorScheme[colorScheme].text,
|
||||
STYLES.size[size].item,
|
||||
getItemStateClasses(variant, colorScheme, false),
|
||||
className
|
||||
)}
|
||||
role='button'
|
||||
onClick={closeFolder}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className={POPOVER_ICON_SIZE_CLASSES[size]} />
|
||||
<ChevronLeft className={STYLES.size[size].icon} />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
{folderTitle && onFolderSelect && (
|
||||
<div
|
||||
ref={folderTitleRef}
|
||||
className={cn(
|
||||
POPOVER_ITEM_BASE_CLASSES,
|
||||
POPOVER_ITEM_SIZE_CLASSES[size],
|
||||
folderTitleActive
|
||||
? POPOVER_ITEM_ACTIVE_CLASSES[variant]
|
||||
: POPOVER_ITEM_HOVER_CLASSES[variant],
|
||||
// Hide active/hover background when back button is hovered
|
||||
STYLES.itemBase,
|
||||
STYLES.colorScheme[colorScheme].text,
|
||||
STYLES.size[size].item,
|
||||
getItemStateClasses(variant, colorScheme, !!folderTitleActive),
|
||||
'peer-hover:!bg-transparent'
|
||||
)}
|
||||
role='button'
|
||||
@@ -789,8 +694,9 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
|
||||
{folderTitle && !onFolderSelect && (
|
||||
<div
|
||||
className={cn(
|
||||
'font-base text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]',
|
||||
POPOVER_SECTION_SIZE_CLASSES[size]
|
||||
'font-base',
|
||||
STYLES.colorScheme[colorScheme].section,
|
||||
STYLES.size[size].section
|
||||
)}
|
||||
>
|
||||
{folderTitle}
|
||||
@@ -805,43 +711,20 @@ PopoverBackButton.displayName = 'PopoverBackButton'
|
||||
|
||||
export interface PopoverSearchProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Placeholder text for the search input
|
||||
* Placeholder text
|
||||
* @default 'Search...'
|
||||
*/
|
||||
placeholder?: string
|
||||
/**
|
||||
* Callback when search query changes
|
||||
*/
|
||||
/** Callback when query changes */
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Size-specific styles for popover search container.
|
||||
* Shared: padding
|
||||
*/
|
||||
const POPOVER_SEARCH_SIZE_CLASSES: Record<PopoverSize, string> = {
|
||||
sm: 'px-[8px] py-[6px] text-[11px]',
|
||||
md: 'px-[8px] py-[6px] text-[13px]',
|
||||
}
|
||||
|
||||
/**
|
||||
* Search input component for filtering popover items.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Popover>
|
||||
* <PopoverContent>
|
||||
* <PopoverSearch placeholder="Search tools..." />
|
||||
* <PopoverScrollArea>
|
||||
* // items
|
||||
* </PopoverScrollArea>
|
||||
* </PopoverContent>
|
||||
* </Popover>
|
||||
* ```
|
||||
* Search input for filtering popover items.
|
||||
*/
|
||||
const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
|
||||
({ className, placeholder = 'Search...', onValueChange, ...props }, ref) => {
|
||||
const { searchQuery, setSearchQuery, size } = usePopoverContext()
|
||||
const { searchQuery, setSearchQuery, size, colorScheme } = usePopoverContext()
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -857,18 +740,19 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
|
||||
}, [setSearchQuery, onValueChange])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center', POPOVER_SEARCH_SIZE_CLASSES[size], className)}
|
||||
{...props}
|
||||
>
|
||||
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>
|
||||
<Search
|
||||
className={cn('mr-2 shrink-0 text-[var(--text-muted)]', POPOVER_ICON_SIZE_CLASSES[size])}
|
||||
className={cn(
|
||||
'mr-2 shrink-0',
|
||||
STYLES.colorScheme[colorScheme].search,
|
||||
STYLES.size[size].icon
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
'w-full bg-transparent font-base text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none',
|
||||
'w-full bg-transparent font-base focus:outline-none',
|
||||
STYLES.colorScheme[colorScheme].searchInput,
|
||||
size === 'sm' ? 'text-[11px]' : 'text-[13px]'
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
@@ -882,6 +766,34 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
|
||||
|
||||
PopoverSearch.displayName = 'PopoverSearch'
|
||||
|
||||
export interface PopoverDividerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Only show when not inside any folder */
|
||||
rootOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal divider for separating popover sections.
|
||||
*/
|
||||
const PopoverDivider = React.forwardRef<HTMLDivElement, PopoverDividerProps>(
|
||||
({ className, rootOnly, ...props }, ref) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
const colorScheme = context?.colorScheme || 'default'
|
||||
|
||||
if (rootOnly && context?.isInFolder) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('my-[6px] border-t', STYLES.colorScheme[colorScheme].divider, className)}
|
||||
role='separator'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PopoverDivider.displayName = 'PopoverDivider'
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
@@ -893,7 +805,8 @@ export {
|
||||
PopoverFolder,
|
||||
PopoverBackButton,
|
||||
PopoverSearch,
|
||||
PopoverDivider,
|
||||
usePopoverContext,
|
||||
}
|
||||
|
||||
export type { PopoverSize }
|
||||
export type { PopoverSize, PopoverColorScheme }
|
||||
|
||||
@@ -45,13 +45,13 @@ const Content = React.forwardRef<
|
||||
collisionPadding={8}
|
||||
avoidCollisions={true}
|
||||
className={cn(
|
||||
'z-[10000300] rounded-[3px] bg-black px-[7.5px] py-[6px] font-base text-white text-xs shadow-md dark:bg-white dark:text-black',
|
||||
'z-[10000300] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
<TooltipPrimitive.Arrow className='fill-black dark:fill-white' />
|
||||
<TooltipPrimitive.Arrow className='fill-[#1b1b1b] dark:fill-[#fdfdfd]' />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
|
||||
@@ -11,7 +11,6 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
// Create regex pattern for all search terms
|
||||
const searchTerms = searchQuery
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
@@ -35,7 +34,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
|
||||
return isMatch ? (
|
||||
<span
|
||||
key={index}
|
||||
className='bg-yellow-200 text-yellow-900 dark:bg-yellow-900/50 dark:text-yellow-200'
|
||||
className='bg-[#bae6fd] text-[#0369a1] dark:bg-[rgba(51,180,255,0.2)] dark:text-[var(--brand-secondary)]'
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum BlockType {
|
||||
PARALLEL = 'parallel',
|
||||
LOOP = 'loop',
|
||||
ROUTER = 'router',
|
||||
ROUTER_V2 = 'router_v2',
|
||||
CONDITION = 'condition',
|
||||
|
||||
START_TRIGGER = 'start_trigger',
|
||||
@@ -271,7 +272,11 @@ export function isConditionBlockType(blockType: string | undefined): boolean {
|
||||
}
|
||||
|
||||
export function isRouterBlockType(blockType: string | undefined): boolean {
|
||||
return blockType === BlockType.ROUTER
|
||||
return blockType === BlockType.ROUTER || blockType === BlockType.ROUTER_V2
|
||||
}
|
||||
|
||||
export function isRouterV2BlockType(blockType: string | undefined): boolean {
|
||||
return blockType === BlockType.ROUTER_V2
|
||||
}
|
||||
|
||||
export function isAgentBlockType(blockType: string | undefined): boolean {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { EDGE, isConditionBlockType, isRouterBlockType } from '@/executor/constants'
|
||||
import {
|
||||
EDGE,
|
||||
isConditionBlockType,
|
||||
isRouterBlockType,
|
||||
isRouterV2BlockType,
|
||||
} from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
@@ -19,10 +24,17 @@ interface ConditionConfig {
|
||||
condition: string
|
||||
}
|
||||
|
||||
interface RouterV2RouteConfig {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface EdgeMetadata {
|
||||
blockTypeMap: Map<string, string>
|
||||
conditionConfigMap: Map<string, ConditionConfig[]>
|
||||
routerBlockIds: Set<string>
|
||||
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
|
||||
}
|
||||
|
||||
export class EdgeConstructor {
|
||||
@@ -58,6 +70,7 @@ export class EdgeConstructor {
|
||||
const blockTypeMap = new Map<string, string>()
|
||||
const conditionConfigMap = new Map<string, ConditionConfig[]>()
|
||||
const routerBlockIds = new Set<string>()
|
||||
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
|
||||
|
||||
for (const block of workflow.blocks) {
|
||||
const blockType = block.metadata?.id ?? ''
|
||||
@@ -69,12 +82,19 @@ export class EdgeConstructor {
|
||||
if (conditions) {
|
||||
conditionConfigMap.set(block.id, conditions)
|
||||
}
|
||||
} else if (isRouterV2BlockType(blockType)) {
|
||||
// Router V2 uses port-based routing with route configs
|
||||
const routes = this.parseRouterV2Config(block)
|
||||
if (routes) {
|
||||
routerV2ConfigMap.set(block.id, routes)
|
||||
}
|
||||
} else if (isRouterBlockType(blockType)) {
|
||||
// Legacy router uses target block IDs
|
||||
routerBlockIds.add(block.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { blockTypeMap, conditionConfigMap, routerBlockIds }
|
||||
return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
|
||||
}
|
||||
|
||||
private parseConditionConfig(block: any): ConditionConfig[] | null {
|
||||
@@ -100,6 +120,29 @@ export class EdgeConstructor {
|
||||
}
|
||||
}
|
||||
|
||||
private parseRouterV2Config(block: any): RouterV2RouteConfig[] | null {
|
||||
try {
|
||||
const routesJson = block.config.params?.routes
|
||||
|
||||
if (typeof routesJson === 'string') {
|
||||
return JSON.parse(routesJson)
|
||||
}
|
||||
|
||||
if (Array.isArray(routesJson)) {
|
||||
return routesJson
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse router v2 config', {
|
||||
blockId: block.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private generateSourceHandle(
|
||||
source: string,
|
||||
target: string,
|
||||
@@ -123,6 +166,26 @@ export class EdgeConstructor {
|
||||
}
|
||||
}
|
||||
|
||||
// Router V2 uses port-based routing - handle is already set from UI (router-{routeId})
|
||||
// We don't modify it here, just validate it exists
|
||||
if (metadata.routerV2ConfigMap.has(source)) {
|
||||
// For router_v2, the sourceHandle should already be set from the UI
|
||||
// If not set and not an error handle, generate based on route index
|
||||
if (!handle || (!handle.startsWith(EDGE.ROUTER_PREFIX) && handle !== EDGE.ERROR)) {
|
||||
const routes = metadata.routerV2ConfigMap.get(source)
|
||||
if (routes && routes.length > 0) {
|
||||
const edgesFromRouter = workflow.connections.filter((c) => c.source === source)
|
||||
const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target)
|
||||
|
||||
if (edgeIndex >= 0 && edgeIndex < routes.length) {
|
||||
const correspondingRoute = routes[edgeIndex]
|
||||
handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy router uses target block ID
|
||||
if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
|
||||
handle = `${EDGE.ROUTER_PREFIX}${target}`
|
||||
}
|
||||
|
||||
@@ -331,6 +331,22 @@ export class BlockExecutor {
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
const isTrigger =
|
||||
block.metadata?.category === 'triggers' ||
|
||||
block.config?.params?.triggerMode === true ||
|
||||
block.metadata?.id === BlockType.STARTER
|
||||
|
||||
if (isTrigger) {
|
||||
const filtered: NormalizedBlockOutput = {}
|
||||
const internalKeys = ['webhook', 'workflowId', 'input']
|
||||
for (const [key, value] of Object.entries(output)) {
|
||||
if (internalKeys.includes(key)) continue
|
||||
filtered[key] = value
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -510,7 +526,7 @@ export class BlockExecutor {
|
||||
const placeholderState: BlockState = {
|
||||
output: {
|
||||
url: resumeLinks.uiUrl,
|
||||
// apiUrl: resumeLinks.apiUrl, // Hidden from output
|
||||
resumeEndpoint: resumeLinks.apiUrl,
|
||||
},
|
||||
executed: false,
|
||||
executionTime: existingState?.executionTime ?? 0,
|
||||
|
||||
@@ -227,7 +227,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
|
||||
if (resumeLinks) {
|
||||
output.url = resumeLinks.uiUrl
|
||||
// output.apiUrl = resumeLinks.apiUrl // Hidden from output
|
||||
output.resumeEndpoint = resumeLinks.apiUrl
|
||||
}
|
||||
|
||||
return output
|
||||
@@ -576,9 +576,9 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
if (context.resumeLinks.uiUrl) {
|
||||
pauseOutput.url = context.resumeLinks.uiUrl
|
||||
}
|
||||
// if (context.resumeLinks.apiUrl) {
|
||||
// pauseOutput.apiUrl = context.resumeLinks.apiUrl
|
||||
// } // Hidden from output
|
||||
if (context.resumeLinks.apiUrl) {
|
||||
pauseOutput.resumeEndpoint = context.resumeLinks.apiUrl
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(context.inputFormat)) {
|
||||
|
||||
@@ -4,29 +4,60 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { generateRouterPrompt } from '@/blocks/blocks/router'
|
||||
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType, DEFAULTS, HTTP, isAgentBlockType, ROUTER } from '@/executor/constants'
|
||||
import {
|
||||
BlockType,
|
||||
DEFAULTS,
|
||||
HTTP,
|
||||
isAgentBlockType,
|
||||
isRouterV2BlockType,
|
||||
ROUTER,
|
||||
} from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('RouterBlockHandler')
|
||||
|
||||
interface RouteDefinition {
|
||||
id: string
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Router blocks that dynamically select execution paths.
|
||||
* Supports both legacy router (block-based) and router_v2 (port-based).
|
||||
*/
|
||||
export class RouterBlockHandler implements BlockHandler {
|
||||
constructor(private pathTracker?: any) {}
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.ROUTER
|
||||
return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
|
||||
}
|
||||
|
||||
async execute(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const isV2 = isRouterV2BlockType(block.metadata?.id)
|
||||
|
||||
if (isV2) {
|
||||
return this.executeV2(ctx, block, inputs)
|
||||
}
|
||||
|
||||
return this.executeLegacy(ctx, block, inputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute legacy router (block-based routing).
|
||||
*/
|
||||
private async executeLegacy(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const targetBlocks = this.getTargetBlocks(ctx, block)
|
||||
|
||||
@@ -144,6 +175,168 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute router v2 (port-based routing).
|
||||
* Uses route definitions with descriptions instead of downstream block names.
|
||||
*/
|
||||
private async executeV2(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const routes = this.parseRoutes(inputs.routes)
|
||||
|
||||
if (routes.length === 0) {
|
||||
throw new Error('No routes defined for router')
|
||||
}
|
||||
|
||||
const routerConfig = {
|
||||
context: inputs.context,
|
||||
model: inputs.model || ROUTER.DEFAULT_MODEL,
|
||||
apiKey: inputs.apiKey,
|
||||
vertexProject: inputs.vertexProject,
|
||||
vertexLocation: inputs.vertexLocation,
|
||||
vertexCredential: inputs.vertexCredential,
|
||||
}
|
||||
|
||||
const providerId = getProviderFromModel(routerConfig.model)
|
||||
|
||||
try {
|
||||
const url = new URL('/api/providers', getBaseUrl())
|
||||
|
||||
const messages = [{ role: 'user', content: routerConfig.context }]
|
||||
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
|
||||
|
||||
let finalApiKey: string | undefined = routerConfig.apiKey
|
||||
if (providerId === 'vertex' && routerConfig.vertexCredential) {
|
||||
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
|
||||
}
|
||||
|
||||
const providerRequest: Record<string, any> = {
|
||||
provider: providerId,
|
||||
model: routerConfig.model,
|
||||
systemPrompt: systemPrompt,
|
||||
context: JSON.stringify(messages),
|
||||
temperature: ROUTER.INFERENCE_TEMPERATURE,
|
||||
apiKey: finalApiKey,
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}
|
||||
|
||||
if (providerId === 'vertex') {
|
||||
providerRequest.vertexProject = routerConfig.vertexProject
|
||||
providerRequest.vertexLocation = routerConfig.vertexLocation
|
||||
}
|
||||
|
||||
if (providerId === 'azure-openai') {
|
||||
providerRequest.azureEndpoint = inputs.azureEndpoint
|
||||
providerRequest.azureApiVersion = inputs.azureApiVersion
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': HTTP.CONTENT_TYPE.JSON,
|
||||
},
|
||||
body: JSON.stringify(providerRequest),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Provider API request failed with status ${response.status}`
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
if (errorData.error) {
|
||||
errorMessage = errorData.error
|
||||
}
|
||||
} catch (_e) {}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
const chosenRouteId = result.content.trim()
|
||||
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
|
||||
|
||||
if (!chosenRoute) {
|
||||
logger.error(
|
||||
`Invalid routing decision. Response content: "${result.content}", available routes:`,
|
||||
routes.map((r) => ({ id: r.id, title: r.title }))
|
||||
)
|
||||
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
|
||||
}
|
||||
|
||||
// Find the target block connected to this route's handle
|
||||
const connection = ctx.workflow?.connections.find(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
|
||||
)
|
||||
|
||||
const targetBlock = connection
|
||||
? ctx.workflow?.blocks.find((b) => b.id === connection.target)
|
||||
: null
|
||||
|
||||
const tokens = result.tokens || {
|
||||
input: DEFAULTS.TOKENS.PROMPT,
|
||||
output: DEFAULTS.TOKENS.COMPLETION,
|
||||
total: DEFAULTS.TOKENS.TOTAL,
|
||||
}
|
||||
|
||||
const cost = calculateCost(
|
||||
result.model,
|
||||
tokens.input || DEFAULTS.TOKENS.PROMPT,
|
||||
tokens.output || DEFAULTS.TOKENS.COMPLETION,
|
||||
false
|
||||
)
|
||||
|
||||
return {
|
||||
context: inputs.context,
|
||||
model: result.model,
|
||||
tokens: {
|
||||
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
|
||||
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
|
||||
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
|
||||
},
|
||||
cost: {
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
total: cost.total,
|
||||
},
|
||||
selectedRoute: chosenRoute.id,
|
||||
selectedPath: targetBlock
|
||||
? {
|
||||
blockId: targetBlock.id,
|
||||
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
|
||||
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
|
||||
}
|
||||
: {
|
||||
blockId: '',
|
||||
blockType: DEFAULTS.BLOCK_TYPE,
|
||||
blockTitle: chosenRoute.title,
|
||||
},
|
||||
} as BlockOutput
|
||||
} catch (error) {
|
||||
logger.error('Router V2 execution failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse routes from input (can be JSON string or array).
|
||||
*/
|
||||
private parseRoutes(input: any): RouteDefinition[] {
|
||||
try {
|
||||
if (typeof input === 'string') {
|
||||
return JSON.parse(input)
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse routes:', { input, error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
|
||||
return ctx.workflow?.connections
|
||||
.filter((conn) => conn.source === block.id)
|
||||
|
||||
@@ -205,7 +205,6 @@ describe('TriggerBlockHandler', () => {
|
||||
|
||||
const result = await handler.execute(mockContext, scheduleBlock, {})
|
||||
|
||||
// Schedule triggers typically don't have input data, just trigger the workflow
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
|
||||
@@ -31,10 +31,7 @@ export class TriggerBlockHandler implements BlockHandler {
|
||||
|
||||
const existingState = ctx.blockStates.get(block.id)
|
||||
if (existingState?.output && Object.keys(existingState.output).length > 0) {
|
||||
const existingOutput = existingState.output as any
|
||||
const existingProvider = existingOutput?.webhook?.data?.provider
|
||||
|
||||
return existingOutput
|
||||
return existingState.output
|
||||
}
|
||||
|
||||
const starterBlock = ctx.workflow?.blocks?.find((b) => b.metadata?.id === 'starter')
|
||||
@@ -44,88 +41,8 @@ export class TriggerBlockHandler implements BlockHandler {
|
||||
const starterOutput = starterState.output
|
||||
|
||||
if (starterOutput.webhook?.data) {
|
||||
const webhookData = starterOutput.webhook?.data || {}
|
||||
const provider = webhookData.provider
|
||||
|
||||
if (provider === 'github') {
|
||||
const payloadSource = webhookData.payload || {}
|
||||
return {
|
||||
...payloadSource,
|
||||
webhook: starterOutput.webhook,
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'microsoft-teams') {
|
||||
const providerData = (starterOutput as any)[provider] || webhookData[provider] || {}
|
||||
const payloadSource = providerData?.message?.raw || webhookData.payload || {}
|
||||
return {
|
||||
...payloadSource,
|
||||
[provider]: providerData,
|
||||
webhook: starterOutput.webhook,
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'airtable') {
|
||||
return starterOutput
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
input: starterOutput.input,
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(starterOutput)) {
|
||||
if (key !== 'webhook' && key !== provider) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (provider && starterOutput[provider]) {
|
||||
const providerData = starterOutput[provider]
|
||||
|
||||
for (const [key, value] of Object.entries(providerData)) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (!result[key]) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result[provider] = providerData
|
||||
} else if (provider && webhookData[provider]) {
|
||||
const providerData = webhookData[provider]
|
||||
|
||||
for (const [key, value] of Object.entries(providerData)) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (!result[key]) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result[provider] = providerData
|
||||
} else if (
|
||||
provider &&
|
||||
(provider === 'gmail' || provider === 'outlook') &&
|
||||
webhookData.payload?.email
|
||||
) {
|
||||
const emailData = webhookData.payload.email
|
||||
|
||||
for (const [key, value] of Object.entries(emailData)) {
|
||||
if (!result[key]) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
result.email = emailData
|
||||
|
||||
if (webhookData.payload.timestamp) {
|
||||
result.timestamp = webhookData.payload.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if (starterOutput.webhook) result.webhook = starterOutput.webhook
|
||||
|
||||
return result
|
||||
const { webhook, workflowId, ...cleanOutput } = starterOutput
|
||||
return cleanOutput
|
||||
}
|
||||
|
||||
return starterOutput
|
||||
|
||||
@@ -109,6 +109,9 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
contextExtensions: {
|
||||
isChildExecution: true,
|
||||
isDeployedContext: ctx.isDeployedContext === true,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
executionId: ctx.executionId,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ export function useKnowledgeDocumentsQuery(
|
||||
params: KnowledgeDocumentsParams,
|
||||
options?: {
|
||||
enabled?: boolean
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
) {
|
||||
const paramsKey = serializeDocumentParams(params)
|
||||
@@ -237,6 +238,7 @@ export function useKnowledgeDocumentsQuery(
|
||||
enabled: (options?.enabled ?? true) && Boolean(params.knowledgeBaseId),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export function useKnowledgeBaseDocuments(
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
enabled?: boolean
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -92,6 +93,7 @@ export function useKnowledgeBaseDocuments(
|
||||
},
|
||||
{
|
||||
enabled: (options?.enabled ?? true) && Boolean(knowledgeBaseId),
|
||||
refetchInterval: options?.refetchInterval,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface HeaderInfo {
|
||||
interface Frontmatter {
|
||||
title?: string
|
||||
description?: string
|
||||
[key: string]: any
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const logger = createLogger('DocsChunker')
|
||||
|
||||
@@ -6,6 +6,11 @@ import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||
|
||||
const logger = createLogger('JsonYamlChunker')
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null
|
||||
type JsonValue = JsonPrimitive | JsonObject | JsonArray
|
||||
type JsonObject = { [key: string]: JsonValue }
|
||||
type JsonArray = JsonValue[]
|
||||
|
||||
function getTokenCount(text: string): number {
|
||||
try {
|
||||
return getAccurateTokenCount(text, 'text-embedding-3-small')
|
||||
@@ -59,11 +64,11 @@ export class JsonYamlChunker {
|
||||
*/
|
||||
async chunk(content: string): Promise<Chunk[]> {
|
||||
try {
|
||||
let data: any
|
||||
let data: JsonValue
|
||||
try {
|
||||
data = JSON.parse(content)
|
||||
data = JSON.parse(content) as JsonValue
|
||||
} catch {
|
||||
data = yaml.load(content)
|
||||
data = yaml.load(content) as JsonValue
|
||||
}
|
||||
const chunks = this.chunkStructuredData(data)
|
||||
|
||||
@@ -86,7 +91,7 @@ export class JsonYamlChunker {
|
||||
/**
|
||||
* Chunk structured data based on its structure
|
||||
*/
|
||||
private chunkStructuredData(data: any, path: string[] = []): Chunk[] {
|
||||
private chunkStructuredData(data: JsonValue, path: string[] = []): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
@@ -94,7 +99,7 @@ export class JsonYamlChunker {
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
return this.chunkObject(data, path)
|
||||
return this.chunkObject(data as JsonObject, path)
|
||||
}
|
||||
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
@@ -118,9 +123,9 @@ export class JsonYamlChunker {
|
||||
/**
|
||||
* Chunk an array intelligently
|
||||
*/
|
||||
private chunkArray(arr: any[], path: string[]): Chunk[] {
|
||||
private chunkArray(arr: JsonArray, path: string[]): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
let currentBatch: any[] = []
|
||||
let currentBatch: JsonValue[] = []
|
||||
let currentTokens = 0
|
||||
|
||||
const contextHeader = path.length > 0 ? `// ${path.join('.')}\n` : ''
|
||||
@@ -194,7 +199,7 @@ export class JsonYamlChunker {
|
||||
/**
|
||||
* Chunk an object intelligently
|
||||
*/
|
||||
private chunkObject(obj: Record<string, any>, path: string[]): Chunk[] {
|
||||
private chunkObject(obj: JsonObject, path: string[]): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
const entries = Object.entries(obj)
|
||||
|
||||
@@ -213,7 +218,7 @@ export class JsonYamlChunker {
|
||||
return chunks
|
||||
}
|
||||
|
||||
let currentObj: Record<string, any> = {}
|
||||
let currentObj: JsonObject = {}
|
||||
let currentTokens = 0
|
||||
let currentKeys: string[] = []
|
||||
|
||||
|
||||
@@ -110,10 +110,12 @@ export class TextChunker {
|
||||
chunks.push(currentChunk.trim())
|
||||
}
|
||||
|
||||
// Start new chunk with current part
|
||||
// If part itself is too large, split it further
|
||||
if (this.estimateTokens(part) > this.chunkSize) {
|
||||
chunks.push(...(await this.splitRecursively(part, separatorIndex + 1)))
|
||||
const subChunks = await this.splitRecursively(part, separatorIndex + 1)
|
||||
for (const subChunk of subChunks) {
|
||||
chunks.push(subChunk)
|
||||
}
|
||||
currentChunk = ''
|
||||
} else {
|
||||
currentChunk = part
|
||||
|
||||
@@ -174,10 +174,11 @@ export const env = createEnv({
|
||||
KB_CONFIG_RETRY_FACTOR: z.number().optional().default(2), // Retry backoff factor
|
||||
KB_CONFIG_MIN_TIMEOUT: z.number().optional().default(1000), // Min timeout in ms
|
||||
KB_CONFIG_MAX_TIMEOUT: z.number().optional().default(10000), // Max timeout in ms
|
||||
KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(20), // Queue concurrency limit
|
||||
KB_CONFIG_BATCH_SIZE: z.number().optional().default(20), // Processing batch size
|
||||
KB_CONFIG_DELAY_BETWEEN_BATCHES: z.number().optional().default(100), // Delay between batches in ms
|
||||
KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(50), // Concurrent embedding API calls
|
||||
KB_CONFIG_BATCH_SIZE: z.number().optional().default(2000), // Chunks to process per embedding batch
|
||||
KB_CONFIG_DELAY_BETWEEN_BATCHES: z.number().optional().default(0), // Delay between batches in ms (0 for max speed)
|
||||
KB_CONFIG_DELAY_BETWEEN_DOCUMENTS: z.number().optional().default(50), // Delay between documents in ms
|
||||
KB_CONFIG_CHUNK_CONCURRENCY: z.number().optional().default(10), // Concurrent PDF chunk OCR processing
|
||||
|
||||
// Real-time Communication
|
||||
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
|
||||
|
||||
@@ -17,8 +17,6 @@ export class DocParser implements FileParser {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
logger.info(`Parsing DOC file: ${filePath}`)
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
@@ -29,53 +27,80 @@ export class DocParser implements FileParser {
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Parsing DOC buffer, size:', buffer.length)
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Empty buffer provided')
|
||||
}
|
||||
|
||||
let parseOfficeAsync
|
||||
try {
|
||||
const officeParser = await import('officeparser')
|
||||
parseOfficeAsync = officeParser.parseOfficeAsync
|
||||
} catch (importError) {
|
||||
logger.warn('officeparser not available, using fallback extraction')
|
||||
return this.fallbackExtraction(buffer)
|
||||
const result = await officeParser.parseOfficeAsync(buffer)
|
||||
|
||||
if (result) {
|
||||
const resultString = typeof result === 'string' ? result : String(result)
|
||||
const content = sanitizeTextForUTF8(resultString.trim())
|
||||
|
||||
if (content.length > 0) {
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
characterCount: content.length,
|
||||
extractionMethod: 'officeparser',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (officeError) {
|
||||
logger.warn('officeparser failed, trying mammoth:', officeError)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await parseOfficeAsync(buffer)
|
||||
const mammoth = await import('mammoth')
|
||||
const result = await mammoth.extractRawText({ buffer })
|
||||
|
||||
if (!result) {
|
||||
throw new Error('officeparser returned no result')
|
||||
if (result.value && result.value.trim().length > 0) {
|
||||
const content = sanitizeTextForUTF8(result.value.trim())
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
characterCount: content.length,
|
||||
extractionMethod: 'mammoth',
|
||||
messages: result.messages,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resultString = typeof result === 'string' ? result : String(result)
|
||||
|
||||
const content = sanitizeTextForUTF8(resultString.trim())
|
||||
|
||||
logger.info('DOC parsing completed successfully with officeparser')
|
||||
|
||||
return {
|
||||
content: content,
|
||||
metadata: {
|
||||
characterCount: content.length,
|
||||
extractionMethod: 'officeparser',
|
||||
},
|
||||
}
|
||||
} catch (extractError) {
|
||||
logger.warn('officeparser failed, using fallback:', extractError)
|
||||
return this.fallbackExtraction(buffer)
|
||||
} catch (mammothError) {
|
||||
logger.warn('mammoth failed:', mammothError)
|
||||
}
|
||||
|
||||
return this.fallbackExtraction(buffer)
|
||||
} catch (error) {
|
||||
logger.error('DOC buffer parsing error:', error)
|
||||
logger.error('DOC parsing error:', error)
|
||||
throw new Error(`Failed to parse DOC buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackExtraction(buffer: Buffer): FileParseResult {
|
||||
logger.info('Using fallback text extraction for DOC file')
|
||||
const isBinaryDoc = buffer.length >= 2 && buffer[0] === 0xd0 && buffer[1] === 0xcf
|
||||
|
||||
if (!isBinaryDoc) {
|
||||
const textContent = buffer.toString('utf8').trim()
|
||||
|
||||
if (textContent.length > 0) {
|
||||
const printableChars = textContent.match(/[\x20-\x7E\n\r\t]/g)?.length || 0
|
||||
const isProbablyText = printableChars / textContent.length > 0.9
|
||||
|
||||
if (isProbablyText) {
|
||||
return {
|
||||
content: sanitizeTextForUTF8(textContent),
|
||||
metadata: {
|
||||
extractionMethod: 'plaintext-fallback',
|
||||
characterCount: textContent.length,
|
||||
warning: 'File is not a valid DOC format, extracted as plain text',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = buffer.toString('utf8', 0, Math.min(buffer.length, 100000))
|
||||
|
||||
|
||||
@@ -2,13 +2,18 @@ import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import mammoth from 'mammoth'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
|
||||
const logger = createLogger('DocxParser')
|
||||
|
||||
// Define interface for mammoth result
|
||||
interface MammothMessage {
|
||||
type: 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface MammothResult {
|
||||
value: string
|
||||
messages: any[]
|
||||
messages: MammothMessage[]
|
||||
}
|
||||
|
||||
export class DocxParser implements FileParser {
|
||||
@@ -19,7 +24,6 @@ export class DocxParser implements FileParser {
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('DOCX file error:', error)
|
||||
@@ -29,26 +33,74 @@ export class DocxParser implements FileParser {
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Parsing buffer, size:', buffer.length)
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Empty buffer provided')
|
||||
}
|
||||
|
||||
const result = await mammoth.extractRawText({ buffer })
|
||||
|
||||
let htmlResult: MammothResult = { value: '', messages: [] }
|
||||
try {
|
||||
htmlResult = await mammoth.convertToHtml({ buffer })
|
||||
} catch (htmlError) {
|
||||
logger.warn('HTML conversion warning:', htmlError)
|
||||
const result = await mammoth.extractRawText({ buffer })
|
||||
|
||||
if (result.value && result.value.trim().length > 0) {
|
||||
let htmlResult: MammothResult = { value: '', messages: [] }
|
||||
try {
|
||||
htmlResult = await mammoth.convertToHtml({ buffer })
|
||||
} catch {
|
||||
// HTML conversion is optional
|
||||
}
|
||||
|
||||
return {
|
||||
content: sanitizeTextForUTF8(result.value),
|
||||
metadata: {
|
||||
extractionMethod: 'mammoth',
|
||||
messages: [...result.messages, ...htmlResult.messages],
|
||||
html: htmlResult.value,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (mammothError) {
|
||||
logger.warn('mammoth failed, trying officeparser:', mammothError)
|
||||
}
|
||||
|
||||
return {
|
||||
content: result.value,
|
||||
metadata: {
|
||||
messages: [...result.messages, ...htmlResult.messages],
|
||||
html: htmlResult.value,
|
||||
},
|
||||
try {
|
||||
const officeParser = await import('officeparser')
|
||||
const result = await officeParser.parseOfficeAsync(buffer)
|
||||
|
||||
if (result) {
|
||||
const resultString = typeof result === 'string' ? result : String(result)
|
||||
const content = sanitizeTextForUTF8(resultString.trim())
|
||||
|
||||
if (content.length > 0) {
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
extractionMethod: 'officeparser',
|
||||
characterCount: content.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (officeError) {
|
||||
logger.warn('officeparser failed:', officeError)
|
||||
}
|
||||
|
||||
const isZipFile = buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b
|
||||
if (!isZipFile) {
|
||||
const textContent = buffer.toString('utf8').trim()
|
||||
if (textContent.length > 0) {
|
||||
return {
|
||||
content: sanitizeTextForUTF8(textContent),
|
||||
metadata: {
|
||||
extractionMethod: 'plaintext-fallback',
|
||||
characterCount: textContent.length,
|
||||
warning: 'File is not a valid DOCX format, extracted as plain text',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to extract text from DOCX file')
|
||||
} catch (error) {
|
||||
logger.error('DOCX buffer parsing error:', error)
|
||||
logger.error('DOCX parsing error:', error)
|
||||
throw new Error(`Failed to parse DOCX buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
export interface FileParseMetadata {
|
||||
characterCount?: number
|
||||
pageCount?: number
|
||||
extractionMethod?: string
|
||||
warning?: string
|
||||
messages?: unknown[]
|
||||
html?: string
|
||||
type?: string
|
||||
headers?: string[]
|
||||
totalRows?: number
|
||||
rowCount?: number
|
||||
sheetNames?: string[]
|
||||
source?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface FileParseResult {
|
||||
content: string
|
||||
metadata?: Record<string, any>
|
||||
metadata?: FileParseMetadata
|
||||
}
|
||||
|
||||
export interface FileParser {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import { getBYOKKey } from '@/lib/api-key/byok'
|
||||
import { type Chunk, JsonYamlChunker, StructuredDataChunker, TextChunker } from '@/lib/chunkers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { parseBuffer, parseFile } from '@/lib/file-parsers'
|
||||
import type { FileParseMetadata } from '@/lib/file-parsers/types'
|
||||
import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
|
||||
@@ -15,6 +17,8 @@ const TIMEOUTS = {
|
||||
MISTRAL_OCR_API: 120000,
|
||||
} as const
|
||||
|
||||
const MAX_CONCURRENT_CHUNKS = env.KB_CONFIG_CHUNK_CONCURRENCY
|
||||
|
||||
type OCRResult = {
|
||||
success: boolean
|
||||
error?: string
|
||||
@@ -36,6 +40,61 @@ type OCRRequestBody = {
|
||||
include_image_base64: boolean
|
||||
}
|
||||
|
||||
const MISTRAL_MAX_PAGES = 1000
|
||||
|
||||
/**
|
||||
* Get page count from a PDF buffer using unpdf
|
||||
*/
|
||||
async function getPdfPageCount(buffer: Buffer): Promise<number> {
|
||||
try {
|
||||
const { getDocumentProxy } = await import('unpdf')
|
||||
const uint8Array = new Uint8Array(buffer)
|
||||
const pdf = await getDocumentProxy(uint8Array)
|
||||
return pdf.numPages
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get PDF page count:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a PDF buffer into multiple smaller PDFs
|
||||
* Returns an array of PDF buffers, each with at most maxPages pages
|
||||
*/
|
||||
async function splitPdfIntoChunks(
|
||||
pdfBuffer: Buffer,
|
||||
maxPages: number
|
||||
): Promise<{ buffer: Buffer; startPage: number; endPage: number }[]> {
|
||||
const sourcePdf = await PDFDocument.load(pdfBuffer)
|
||||
const totalPages = sourcePdf.getPageCount()
|
||||
|
||||
if (totalPages <= maxPages) {
|
||||
return [{ buffer: pdfBuffer, startPage: 0, endPage: totalPages - 1 }]
|
||||
}
|
||||
|
||||
const chunks: { buffer: Buffer; startPage: number; endPage: number }[] = []
|
||||
|
||||
for (let startPage = 0; startPage < totalPages; startPage += maxPages) {
|
||||
const endPage = Math.min(startPage + maxPages - 1, totalPages - 1)
|
||||
const pageCount = endPage - startPage + 1
|
||||
|
||||
const newPdf = await PDFDocument.create()
|
||||
const pageIndices = Array.from({ length: pageCount }, (_, i) => startPage + i)
|
||||
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices)
|
||||
|
||||
copiedPages.forEach((page) => newPdf.addPage(page))
|
||||
|
||||
const pdfBytes = await newPdf.save()
|
||||
chunks.push({
|
||||
buffer: Buffer.from(pdfBytes),
|
||||
startPage,
|
||||
endPage,
|
||||
})
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
type AzureOCRResponse = {
|
||||
pages?: OCRPage[]
|
||||
[key: string]: unknown
|
||||
@@ -81,7 +140,7 @@ export async function processDocument(
|
||||
const cloudUrl = 'cloudUrl' in parseResult ? parseResult.cloudUrl : undefined
|
||||
|
||||
let chunks: Chunk[]
|
||||
const metadata = 'metadata' in parseResult ? parseResult.metadata : {}
|
||||
const metadata: FileParseMetadata = parseResult.metadata ?? {}
|
||||
|
||||
const isJsonYaml =
|
||||
metadata.type === 'json' ||
|
||||
@@ -97,10 +156,11 @@ export async function processDocument(
|
||||
})
|
||||
} else if (StructuredDataChunker.isStructuredData(content, mimeType)) {
|
||||
logger.info('Using structured data chunker for spreadsheet/CSV content')
|
||||
const rowCount = metadata.totalRows ?? metadata.rowCount
|
||||
chunks = await StructuredDataChunker.chunkStructuredData(content, {
|
||||
chunkSize,
|
||||
headers: metadata.headers,
|
||||
totalRows: metadata.totalRows || metadata.rowCount,
|
||||
totalRows: typeof rowCount === 'number' ? rowCount : undefined,
|
||||
sheetName: metadata.sheetNames?.[0],
|
||||
})
|
||||
} else {
|
||||
@@ -153,7 +213,7 @@ async function parseDocument(
|
||||
content: string
|
||||
processingMethod: 'file-parser' | 'mistral-ocr'
|
||||
cloudUrl?: string
|
||||
metadata?: any
|
||||
metadata?: FileParseMetadata
|
||||
}> {
|
||||
const isPDF = mimeType === 'application/pdf'
|
||||
const hasAzureMistralOCR =
|
||||
@@ -165,7 +225,7 @@ async function parseDocument(
|
||||
if (isPDF && (hasAzureMistralOCR || hasMistralOCR)) {
|
||||
if (hasAzureMistralOCR) {
|
||||
logger.info(`Using Azure Mistral OCR: ${filename}`)
|
||||
return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId, workspaceId)
|
||||
return parseWithAzureMistralOCR(fileUrl, filename, mimeType)
|
||||
}
|
||||
|
||||
if (hasMistralOCR) {
|
||||
@@ -188,13 +248,32 @@ async function handleFileForOCR(
|
||||
const isExternalHttps = fileUrl.startsWith('https://') && !fileUrl.includes('/api/files/serve/')
|
||||
|
||||
if (isExternalHttps) {
|
||||
return { httpsUrl: fileUrl }
|
||||
if (mimeType === 'application/pdf') {
|
||||
logger.info(`handleFileForOCR: Downloading external PDF to check page count`)
|
||||
try {
|
||||
const buffer = await downloadFileWithTimeout(fileUrl)
|
||||
logger.info(`handleFileForOCR: Downloaded external PDF: ${buffer.length} bytes`)
|
||||
return { httpsUrl: fileUrl, buffer }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`handleFileForOCR: Failed to download external PDF for page count check, proceeding without batching`,
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
)
|
||||
return { httpsUrl: fileUrl, buffer: undefined }
|
||||
}
|
||||
}
|
||||
logger.info(`handleFileForOCR: Using external URL directly`)
|
||||
return { httpsUrl: fileUrl, buffer: undefined }
|
||||
}
|
||||
|
||||
logger.info(`Uploading "${filename}" to cloud storage for OCR`)
|
||||
|
||||
const buffer = await downloadFileWithTimeout(fileUrl)
|
||||
|
||||
logger.info(`Downloaded ${filename}: ${buffer.length} bytes`)
|
||||
|
||||
try {
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: filename,
|
||||
@@ -224,8 +303,7 @@ async function handleFileForOCR(
|
||||
900 // 15 minutes
|
||||
)
|
||||
|
||||
logger.info(`Successfully uploaded for OCR: ${cloudResult.key}`)
|
||||
return { httpsUrl, cloudUrl: httpsUrl }
|
||||
return { httpsUrl, cloudUrl: httpsUrl, buffer }
|
||||
} catch (uploadError) {
|
||||
const message = uploadError instanceof Error ? uploadError.message : 'Unknown error'
|
||||
throw new Error(`Cloud upload failed: ${message}. Cloud upload is required for OCR.`)
|
||||
@@ -321,13 +399,7 @@ async function makeOCRRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function parseWithAzureMistralOCR(
|
||||
fileUrl: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
userId?: string,
|
||||
workspaceId?: string | null
|
||||
) {
|
||||
async function parseWithAzureMistralOCR(fileUrl: string, filename: string, mimeType: string) {
|
||||
validateOCRConfig(
|
||||
env.OCR_AZURE_API_KEY,
|
||||
env.OCR_AZURE_ENDPOINT,
|
||||
@@ -336,6 +408,19 @@ async function parseWithAzureMistralOCR(
|
||||
)
|
||||
|
||||
const fileBuffer = await downloadFileForBase64(fileUrl)
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
const pageCount = await getPdfPageCount(fileBuffer)
|
||||
if (pageCount > MISTRAL_MAX_PAGES) {
|
||||
logger.info(
|
||||
`PDF has ${pageCount} pages, exceeds Azure OCR limit of ${MISTRAL_MAX_PAGES}. ` +
|
||||
`Falling back to file parser.`
|
||||
)
|
||||
return parseWithFileParser(fileUrl, filename, mimeType)
|
||||
}
|
||||
logger.info(`Azure Mistral OCR: PDF page count for ${filename}: ${pageCount}`)
|
||||
}
|
||||
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
const dataUri = `data:${mimeType};base64,${base64Data}`
|
||||
|
||||
@@ -374,17 +459,7 @@ async function parseWithAzureMistralOCR(
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
const fallbackMistralKey = await getMistralApiKey(workspaceId)
|
||||
if (fallbackMistralKey) {
|
||||
return parseWithMistralOCR(
|
||||
fileUrl,
|
||||
filename,
|
||||
mimeType,
|
||||
userId,
|
||||
workspaceId,
|
||||
fallbackMistralKey
|
||||
)
|
||||
}
|
||||
logger.info(`Falling back to file parser: ${filename}`)
|
||||
return parseWithFileParser(fileUrl, filename, mimeType)
|
||||
}
|
||||
}
|
||||
@@ -406,50 +481,35 @@ async function parseWithMistralOCR(
|
||||
throw new Error('Mistral parser tool not configured')
|
||||
}
|
||||
|
||||
const { httpsUrl, cloudUrl } = await handleFileForOCR(
|
||||
const { httpsUrl, cloudUrl, buffer } = await handleFileForOCR(
|
||||
fileUrl,
|
||||
filename,
|
||||
mimeType,
|
||||
userId,
|
||||
workspaceId
|
||||
)
|
||||
|
||||
logger.info(`Mistral OCR: Using presigned URL for ${filename}: ${httpsUrl.substring(0, 120)}...`)
|
||||
|
||||
let pageCount = 0
|
||||
if (mimeType === 'application/pdf' && buffer) {
|
||||
pageCount = await getPdfPageCount(buffer)
|
||||
logger.info(`PDF page count for ${filename}: ${pageCount}`)
|
||||
}
|
||||
|
||||
const needsBatching = pageCount > MISTRAL_MAX_PAGES
|
||||
|
||||
if (needsBatching && buffer) {
|
||||
logger.info(
|
||||
`PDF has ${pageCount} pages, exceeds limit of ${MISTRAL_MAX_PAGES}. Splitting and processing in chunks.`
|
||||
)
|
||||
return processMistralOCRInBatches(filename, apiKey, buffer, userId, cloudUrl)
|
||||
}
|
||||
|
||||
const params = { filePath: httpsUrl, apiKey, resultType: 'text' as const }
|
||||
|
||||
try {
|
||||
const response = await retryWithExponentialBackoff(
|
||||
async () => {
|
||||
let url =
|
||||
typeof mistralParserTool.request!.url === 'function'
|
||||
? mistralParserTool.request!.url(params)
|
||||
: mistralParserTool.request!.url
|
||||
|
||||
const isInternalRoute = url.startsWith('/')
|
||||
|
||||
if (isInternalRoute) {
|
||||
const { getBaseUrl } = await import('@/lib/core/utils/urls')
|
||||
url = `${getBaseUrl()}${url}`
|
||||
}
|
||||
|
||||
let headers =
|
||||
typeof mistralParserTool.request!.headers === 'function'
|
||||
? mistralParserTool.request!.headers(params)
|
||||
: mistralParserTool.request!.headers
|
||||
|
||||
if (isInternalRoute) {
|
||||
const { generateInternalToken } = await import('@/lib/auth/internal')
|
||||
const internalToken = await generateInternalToken(userId)
|
||||
headers = {
|
||||
...headers,
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = mistralParserTool.request!.body!(params) as OCRRequestBody
|
||||
return makeOCRRequest(url, headers as Record<string, string>, requestBody)
|
||||
},
|
||||
{ maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 10000 }
|
||||
)
|
||||
|
||||
const response = await executeMistralOCRRequest(params, userId)
|
||||
const result = (await mistralParserTool.transformResponse!(response, params)) as OCRResult
|
||||
const content = processOCRContent(result, filename)
|
||||
|
||||
@@ -464,10 +524,204 @@ async function parseWithMistralOCR(
|
||||
}
|
||||
}
|
||||
|
||||
async function executeMistralOCRRequest(
|
||||
params: { filePath: string; apiKey: string; resultType: 'text' },
|
||||
userId?: string
|
||||
): Promise<Response> {
|
||||
return retryWithExponentialBackoff(
|
||||
async () => {
|
||||
let url =
|
||||
typeof mistralParserTool.request!.url === 'function'
|
||||
? mistralParserTool.request!.url(params)
|
||||
: mistralParserTool.request!.url
|
||||
|
||||
const isInternalRoute = url.startsWith('/')
|
||||
|
||||
if (isInternalRoute) {
|
||||
const { getBaseUrl } = await import('@/lib/core/utils/urls')
|
||||
url = `${getBaseUrl()}${url}`
|
||||
}
|
||||
|
||||
let headers =
|
||||
typeof mistralParserTool.request!.headers === 'function'
|
||||
? mistralParserTool.request!.headers(params)
|
||||
: mistralParserTool.request!.headers
|
||||
|
||||
if (isInternalRoute) {
|
||||
const { generateInternalToken } = await import('@/lib/auth/internal')
|
||||
const internalToken = await generateInternalToken(userId)
|
||||
headers = {
|
||||
...headers,
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = mistralParserTool.request!.body!(params) as OCRRequestBody
|
||||
return makeOCRRequest(url, headers as Record<string, string>, requestBody)
|
||||
},
|
||||
{ maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 10000 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single PDF chunk: upload to S3, OCR, cleanup
|
||||
*/
|
||||
async function processChunk(
|
||||
chunk: { buffer: Buffer; startPage: number; endPage: number },
|
||||
chunkIndex: number,
|
||||
totalChunks: number,
|
||||
filename: string,
|
||||
apiKey: string,
|
||||
userId?: string
|
||||
): Promise<{ index: number; content: string | null }> {
|
||||
const chunkPageCount = chunk.endPage - chunk.startPage + 1
|
||||
|
||||
logger.info(
|
||||
`Processing chunk ${chunkIndex + 1}/${totalChunks} (pages ${chunk.startPage + 1}-${chunk.endPage + 1}, ${chunkPageCount} pages)`
|
||||
)
|
||||
|
||||
let uploadedKey: string | null = null
|
||||
|
||||
try {
|
||||
// Upload the chunk to S3
|
||||
const timestamp = Date.now()
|
||||
const uniqueId = Math.random().toString(36).substring(2, 9)
|
||||
const safeFileName = filename.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
const chunkKey = `kb/${timestamp}-${uniqueId}-chunk${chunkIndex + 1}-${safeFileName}`
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: `${filename}_chunk${chunkIndex + 1}`,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: 'knowledge-base',
|
||||
...(userId && { userId }),
|
||||
}
|
||||
|
||||
const uploadResult = await StorageService.uploadFile({
|
||||
file: chunk.buffer,
|
||||
fileName: `${filename}_chunk${chunkIndex + 1}`,
|
||||
contentType: 'application/pdf',
|
||||
context: 'knowledge-base',
|
||||
customKey: chunkKey,
|
||||
metadata,
|
||||
})
|
||||
|
||||
uploadedKey = uploadResult.key
|
||||
|
||||
const chunkUrl = await StorageService.generatePresignedDownloadUrl(
|
||||
uploadResult.key,
|
||||
'knowledge-base',
|
||||
900 // 15 minutes
|
||||
)
|
||||
|
||||
logger.info(`Uploaded chunk ${chunkIndex + 1} to S3: ${chunkKey}`)
|
||||
|
||||
// Process the chunk with Mistral OCR
|
||||
const params = {
|
||||
filePath: chunkUrl,
|
||||
apiKey,
|
||||
resultType: 'text' as const,
|
||||
}
|
||||
|
||||
const response = await executeMistralOCRRequest(params, userId)
|
||||
const result = (await mistralParserTool.transformResponse!(response, params)) as OCRResult
|
||||
|
||||
if (result.success && result.output?.content) {
|
||||
logger.info(`Chunk ${chunkIndex + 1}/${totalChunks} completed successfully`)
|
||||
return { index: chunkIndex, content: result.output.content }
|
||||
}
|
||||
logger.warn(`Chunk ${chunkIndex + 1}/${totalChunks} returned no content`)
|
||||
return { index: chunkIndex, content: null }
|
||||
} catch (error) {
|
||||
logger.error(`Chunk ${chunkIndex + 1}/${totalChunks} failed:`, {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return { index: chunkIndex, content: null }
|
||||
} finally {
|
||||
// Clean up the chunk file from S3 after processing
|
||||
if (uploadedKey) {
|
||||
try {
|
||||
await StorageService.deleteFile({ key: uploadedKey, context: 'knowledge-base' })
|
||||
logger.info(`Cleaned up chunk ${chunkIndex + 1} from S3`)
|
||||
} catch (deleteError) {
|
||||
logger.warn(`Failed to clean up chunk ${chunkIndex + 1} from S3:`, {
|
||||
message: deleteError instanceof Error ? deleteError.message : String(deleteError),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processMistralOCRInBatches(
|
||||
filename: string,
|
||||
apiKey: string,
|
||||
pdfBuffer: Buffer,
|
||||
userId?: string,
|
||||
cloudUrl?: string
|
||||
): Promise<{
|
||||
content: string
|
||||
processingMethod: 'mistral-ocr'
|
||||
cloudUrl?: string
|
||||
}> {
|
||||
const totalPages = await getPdfPageCount(pdfBuffer)
|
||||
logger.info(
|
||||
`Splitting ${filename} (${totalPages} pages) into chunks of ${MISTRAL_MAX_PAGES} pages`
|
||||
)
|
||||
|
||||
const pdfChunks = await splitPdfIntoChunks(pdfBuffer, MISTRAL_MAX_PAGES)
|
||||
logger.info(
|
||||
`Split into ${pdfChunks.length} chunks, processing with concurrency ${MAX_CONCURRENT_CHUNKS}`
|
||||
)
|
||||
|
||||
// Process chunks concurrently with limited concurrency
|
||||
const results: { index: number; content: string | null }[] = []
|
||||
|
||||
for (let i = 0; i < pdfChunks.length; i += MAX_CONCURRENT_CHUNKS) {
|
||||
const batch = pdfChunks.slice(i, i + MAX_CONCURRENT_CHUNKS)
|
||||
const batchPromises = batch.map((chunk, batchIndex) =>
|
||||
processChunk(chunk, i + batchIndex, pdfChunks.length, filename, apiKey, userId)
|
||||
)
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
for (const result of batchResults) {
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed batch ${Math.floor(i / MAX_CONCURRENT_CHUNKS) + 1}/${Math.ceil(pdfChunks.length / MAX_CONCURRENT_CHUNKS)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by index to maintain page order and filter out nulls
|
||||
const sortedResults = results
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.filter((r) => r.content !== null)
|
||||
.map((r) => r.content as string)
|
||||
|
||||
if (sortedResults.length === 0) {
|
||||
// Don't fall back to file parser for large PDFs - it produces poor results
|
||||
// Better to fail clearly than return low-quality extraction
|
||||
throw new Error(
|
||||
`OCR failed for all ${pdfChunks.length} chunks of ${filename}. ` +
|
||||
`Large PDFs require OCR - file parser fallback would produce poor results.`
|
||||
)
|
||||
}
|
||||
|
||||
const combinedContent = sortedResults.join('\n\n')
|
||||
logger.info(
|
||||
`Successfully processed ${sortedResults.length}/${pdfChunks.length} chunks for ${filename}`
|
||||
)
|
||||
|
||||
return {
|
||||
content: combinedContent,
|
||||
processingMethod: 'mistral-ocr',
|
||||
cloudUrl,
|
||||
}
|
||||
}
|
||||
|
||||
async function parseWithFileParser(fileUrl: string, filename: string, mimeType: string) {
|
||||
try {
|
||||
let content: string
|
||||
let metadata: any = {}
|
||||
let metadata: FileParseMetadata = {}
|
||||
|
||||
if (fileUrl.startsWith('data:')) {
|
||||
content = await parseDataURI(fileUrl, filename, mimeType)
|
||||
@@ -513,7 +767,7 @@ async function parseDataURI(fileUrl: string, filename: string, mimeType: string)
|
||||
async function parseHttpFile(
|
||||
fileUrl: string,
|
||||
filename: string
|
||||
): Promise<{ content: string; metadata?: any }> {
|
||||
): Promise<{ content: string; metadata?: FileParseMetadata }> {
|
||||
const buffer = await downloadFileWithTimeout(fileUrl)
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
@@ -29,10 +29,10 @@ const TIMEOUTS = {
|
||||
|
||||
// Configuration for handling large documents
|
||||
const LARGE_DOC_CONFIG = {
|
||||
MAX_CHUNKS_PER_BATCH: 500, // Insert embeddings in batches of 500
|
||||
MAX_EMBEDDING_BATCH: 500, // Generate embeddings in batches of 500
|
||||
MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB max file size
|
||||
MAX_CHUNKS_PER_DOCUMENT: 100000, // Maximum chunks allowed per document
|
||||
MAX_CHUNKS_PER_BATCH: 500,
|
||||
MAX_EMBEDDING_BATCH: env.KB_CONFIG_BATCH_SIZE || 2000,
|
||||
MAX_FILE_SIZE: 100 * 1024 * 1024,
|
||||
MAX_CHUNKS_PER_DOCUMENT: 100000,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,7 +212,6 @@ export async function processDocumentTags(
|
||||
return result
|
||||
}
|
||||
|
||||
// Fetch existing tag definitions
|
||||
const existingDefinitions = await db
|
||||
.select()
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
@@ -220,18 +219,15 @@ export async function processDocumentTags(
|
||||
|
||||
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
|
||||
|
||||
// First pass: collect all validation errors
|
||||
const undefinedTags: string[] = []
|
||||
const typeErrors: string[] = []
|
||||
|
||||
for (const tag of tagData) {
|
||||
// Skip if no tag name
|
||||
if (!tag.tagName?.trim()) continue
|
||||
|
||||
const tagName = tag.tagName.trim()
|
||||
const fieldType = tag.fieldType || 'text'
|
||||
|
||||
// For boolean, check if value is defined; for others, check if value is non-empty
|
||||
const hasValue =
|
||||
fieldType === 'boolean'
|
||||
? tag.value !== undefined && tag.value !== null && tag.value !== ''
|
||||
@@ -239,14 +235,12 @@ export async function processDocumentTags(
|
||||
|
||||
if (!hasValue) continue
|
||||
|
||||
// Check if tag exists
|
||||
const existingDef = existingByName.get(tagName)
|
||||
if (!existingDef) {
|
||||
undefinedTags.push(tagName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate value type using shared validation
|
||||
const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value
|
||||
const actualFieldType = existingDef.fieldType || fieldType
|
||||
const validationError = validateTagValue(tagName, String(rawValue), actualFieldType)
|
||||
@@ -255,7 +249,6 @@ export async function processDocumentTags(
|
||||
}
|
||||
}
|
||||
|
||||
// Throw combined error if there are any validation issues
|
||||
if (undefinedTags.length > 0 || typeErrors.length > 0) {
|
||||
const errorParts: string[] = []
|
||||
|
||||
@@ -270,7 +263,6 @@ export async function processDocumentTags(
|
||||
throw new Error(errorParts.join('\n'))
|
||||
}
|
||||
|
||||
// Second pass: process valid tags
|
||||
for (const tag of tagData) {
|
||||
if (!tag.tagName?.trim()) continue
|
||||
|
||||
@@ -285,14 +277,13 @@ export async function processDocumentTags(
|
||||
if (!hasValue) continue
|
||||
|
||||
const existingDef = existingByName.get(tagName)
|
||||
if (!existingDef) continue // Already validated above
|
||||
if (!existingDef) continue
|
||||
|
||||
const targetSlot = existingDef.tagSlot
|
||||
const actualFieldType = existingDef.fieldType || fieldType
|
||||
const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value
|
||||
const stringValue = String(rawValue).trim()
|
||||
|
||||
// Assign value to the slot with proper type conversion (values already validated)
|
||||
if (actualFieldType === 'boolean') {
|
||||
setTagValue(result, targetSlot, parseBooleanValue(stringValue) ?? false)
|
||||
} else if (actualFieldType === 'number') {
|
||||
@@ -440,7 +431,6 @@ export async function processDocumentAsync(
|
||||
|
||||
logger.info(`[${documentId}] Status updated to 'processing', starting document processor`)
|
||||
|
||||
// Use KB's chunkingConfig as fallback if processingOptions not provided
|
||||
const kbConfig = kb[0].chunkingConfig as { maxSize: number; minSize: number; overlap: number }
|
||||
|
||||
await withTimeout(
|
||||
@@ -469,7 +459,6 @@ export async function processDocumentAsync(
|
||||
`[${documentId}] Document parsed successfully, generating embeddings for ${processed.chunks.length} chunks`
|
||||
)
|
||||
|
||||
// Generate embeddings in batches for large documents
|
||||
const chunkTexts = processed.chunks.map((chunk) => chunk.text)
|
||||
const embeddings: number[][] = []
|
||||
|
||||
@@ -485,7 +474,9 @@ export async function processDocumentAsync(
|
||||
|
||||
logger.info(`[${documentId}] Processing embedding batch ${batchNum}/${totalBatches}`)
|
||||
const batchEmbeddings = await generateEmbeddings(batch, undefined, kb[0].workspaceId)
|
||||
embeddings.push(...batchEmbeddings)
|
||||
for (const emb of batchEmbeddings) {
|
||||
embeddings.push(emb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,23 +553,18 @@ export async function processDocumentAsync(
|
||||
}))
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert embeddings in batches for large documents
|
||||
if (embeddingRecords.length > 0) {
|
||||
const batchSize = LARGE_DOC_CONFIG.MAX_CHUNKS_PER_BATCH
|
||||
const totalBatches = Math.ceil(embeddingRecords.length / batchSize)
|
||||
await tx.delete(embedding).where(eq(embedding.documentId, documentId))
|
||||
|
||||
logger.info(
|
||||
`[${documentId}] Inserting ${embeddingRecords.length} embeddings in ${totalBatches} batches`
|
||||
)
|
||||
|
||||
for (let i = 0; i < embeddingRecords.length; i += batchSize) {
|
||||
const batch = embeddingRecords.slice(i, i + batchSize)
|
||||
const batchNum = Math.floor(i / batchSize) + 1
|
||||
const insertBatchSize = LARGE_DOC_CONFIG.MAX_CHUNKS_PER_BATCH
|
||||
const batches: (typeof embeddingRecords)[] = []
|
||||
for (let i = 0; i < embeddingRecords.length; i += insertBatchSize) {
|
||||
batches.push(embeddingRecords.slice(i, i + insertBatchSize))
|
||||
}
|
||||
|
||||
logger.info(`[${documentId}] Inserting ${embeddingRecords.length} embeddings`)
|
||||
for (const batch of batches) {
|
||||
await tx.insert(embedding).values(batch)
|
||||
logger.info(
|
||||
`[${documentId}] Inserted batch ${batchNum}/${totalBatches} (${batch.length} records)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,11 +675,9 @@ export async function createDocumentRecords(
|
||||
requestId: string,
|
||||
userId?: string
|
||||
): Promise<DocumentData[]> {
|
||||
// Check storage limits before creating documents
|
||||
if (userId) {
|
||||
const totalSize = documents.reduce((sum, doc) => sum + doc.fileSize, 0)
|
||||
|
||||
// Get knowledge base owner
|
||||
const kb = await db
|
||||
.select({ userId: knowledgeBase.userId })
|
||||
.from(knowledgeBase)
|
||||
@@ -713,7 +697,7 @@ export async function createDocumentRecords(
|
||||
for (const docData of documents) {
|
||||
const documentId = randomUUID()
|
||||
|
||||
let processedTags: Record<string, any> = {}
|
||||
let processedTags: Partial<ProcessedDocumentTags> = {}
|
||||
|
||||
if (docData.documentTagsData) {
|
||||
try {
|
||||
@@ -722,7 +706,6 @@ export async function createDocumentRecords(
|
||||
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
|
||||
}
|
||||
} catch (error) {
|
||||
// Re-throw validation errors, only catch JSON parse errors
|
||||
if (error instanceof SyntaxError) {
|
||||
logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error)
|
||||
} else {
|
||||
@@ -791,7 +774,6 @@ export async function createDocumentRecords(
|
||||
if (userId) {
|
||||
const totalSize = documents.reduce((sum, doc) => sum + doc.fileSize, 0)
|
||||
|
||||
// Get knowledge base owner
|
||||
const kb = await db
|
||||
.select({ userId: knowledgeBase.userId })
|
||||
.from(knowledgeBase)
|
||||
@@ -1079,7 +1061,7 @@ export async function createSingleDocument(
|
||||
const now = new Date()
|
||||
|
||||
// Process structured tag data if provided
|
||||
let processedTags: Record<string, any> = {
|
||||
let processedTags: ProcessedDocumentTags = {
|
||||
// Text tags (7 slots)
|
||||
tag1: documentData.tag1 ?? null,
|
||||
tag2: documentData.tag2 ?? null,
|
||||
@@ -1555,23 +1537,30 @@ export async function updateDocument(
|
||||
return value || null
|
||||
}
|
||||
|
||||
// Type-safe access to tag slots in updateData
|
||||
type UpdateDataWithTags = typeof updateData & Record<TagSlot, string | undefined>
|
||||
const typedUpdateData = updateData as UpdateDataWithTags
|
||||
|
||||
ALL_TAG_SLOTS.forEach((slot: TagSlot) => {
|
||||
const updateValue = (updateData as any)[slot]
|
||||
const updateValue = typedUpdateData[slot]
|
||||
if (updateValue !== undefined) {
|
||||
;(dbUpdateData as any)[slot] = convertTagValue(slot, updateValue)
|
||||
;(dbUpdateData as Record<TagSlot, string | number | Date | boolean | null>)[slot] =
|
||||
convertTagValue(slot, updateValue)
|
||||
}
|
||||
})
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(document).set(dbUpdateData).where(eq(document.id, documentId))
|
||||
|
||||
const hasTagUpdates = ALL_TAG_SLOTS.some((field) => (updateData as any)[field] !== undefined)
|
||||
const hasTagUpdates = ALL_TAG_SLOTS.some((field) => typedUpdateData[field] !== undefined)
|
||||
|
||||
if (hasTagUpdates) {
|
||||
const embeddingUpdateData: Record<string, any> = {}
|
||||
const embeddingUpdateData: Partial<ProcessedDocumentTags> = {}
|
||||
ALL_TAG_SLOTS.forEach((field) => {
|
||||
if ((updateData as any)[field] !== undefined) {
|
||||
embeddingUpdateData[field] = convertTagValue(field, (updateData as any)[field])
|
||||
if (typedUpdateData[field] !== undefined) {
|
||||
;(embeddingUpdateData as Record<TagSlot, string | number | Date | boolean | null>)[
|
||||
field
|
||||
] = convertTagValue(field, typedUpdateData[field])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface RetryOptions {
|
||||
initialDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
backoffMultiplier?: number
|
||||
retryCondition?: (error: RetryableError) => boolean
|
||||
retryCondition?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
export interface RetryResult<T> {
|
||||
@@ -30,11 +30,18 @@ function hasStatus(
|
||||
return typeof error === 'object' && error !== null && 'status' in error
|
||||
}
|
||||
|
||||
function isRetryableErrorType(error: unknown): error is RetryableError {
|
||||
if (!error) return false
|
||||
if (error instanceof Error) return true
|
||||
if (typeof error === 'object' && ('status' in error || 'message' in error)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Default retry condition for rate limiting errors
|
||||
*/
|
||||
export function isRetryableError(error: RetryableError): boolean {
|
||||
if (!error) return false
|
||||
export function isRetryableError(error: unknown): boolean {
|
||||
if (!isRetryableErrorType(error)) return false
|
||||
|
||||
// Check for rate limiting status codes
|
||||
if (
|
||||
@@ -45,7 +52,7 @@ export function isRetryableError(error: RetryableError): boolean {
|
||||
}
|
||||
|
||||
// Check for rate limiting in error messages
|
||||
const errorMessage = error.message || error.toString()
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const rateLimitKeywords = [
|
||||
'rate limit',
|
||||
'rate_limit',
|
||||
|
||||
@@ -2,11 +2,12 @@ import { createLogger } from '@sim/logger'
|
||||
import { getBYOKKey } from '@/lib/api-key/byok'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
|
||||
import { batchByTokenLimit, getTotalTokenCount } from '@/lib/tokenization'
|
||||
import { batchByTokenLimit } from '@/lib/tokenization'
|
||||
|
||||
const logger = createLogger('EmbeddingUtils')
|
||||
|
||||
const MAX_TOKENS_PER_REQUEST = 8000
|
||||
const MAX_CONCURRENT_BATCHES = env.KB_CONFIG_CONCURRENCY_LIMIT || 50
|
||||
|
||||
export class EmbeddingAPIError extends Error {
|
||||
public status: number
|
||||
@@ -25,6 +26,20 @@ interface EmbeddingConfig {
|
||||
modelName: string
|
||||
}
|
||||
|
||||
interface EmbeddingResponseItem {
|
||||
embedding: number[]
|
||||
index: number
|
||||
}
|
||||
|
||||
interface EmbeddingAPIResponse {
|
||||
data: EmbeddingResponseItem[]
|
||||
model: string
|
||||
usage: {
|
||||
prompt_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
async function getEmbeddingConfig(
|
||||
embeddingModel = 'text-embedding-3-small',
|
||||
workspaceId?: string | null
|
||||
@@ -103,14 +118,14 @@ async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Prom
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.data.map((item: any) => item.embedding)
|
||||
const data: EmbeddingAPIResponse = await response.json()
|
||||
return data.data.map((item) => item.embedding)
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
retryCondition: (error: any) => {
|
||||
retryCondition: (error: unknown) => {
|
||||
if (error instanceof EmbeddingAPIError) {
|
||||
return error.status === 429 || error.status >= 500
|
||||
}
|
||||
@@ -121,8 +136,29 @@ async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Prom
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for multiple texts with token-aware batching
|
||||
* Uses tiktoken for token counting
|
||||
* Process batches with controlled concurrency
|
||||
*/
|
||||
async function processWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
processor: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let currentIndex = 0
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (currentIndex < items.length) {
|
||||
const index = currentIndex++
|
||||
results[index] = await processor(items[index], index)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for multiple texts with token-aware batching and parallel processing
|
||||
*/
|
||||
export async function generateEmbeddings(
|
||||
texts: string[],
|
||||
@@ -131,45 +167,28 @@ export async function generateEmbeddings(
|
||||
): Promise<number[][]> {
|
||||
const config = await getEmbeddingConfig(embeddingModel, workspaceId)
|
||||
|
||||
logger.info(
|
||||
`Using ${config.useAzure ? 'Azure OpenAI' : 'OpenAI'} for embeddings generation (${texts.length} texts)`
|
||||
)
|
||||
|
||||
const batches = batchByTokenLimit(texts, MAX_TOKENS_PER_REQUEST, embeddingModel)
|
||||
|
||||
logger.info(
|
||||
`Split ${texts.length} texts into ${batches.length} batches (max ${MAX_TOKENS_PER_REQUEST} tokens per batch)`
|
||||
const batchResults = await processWithConcurrency(
|
||||
batches,
|
||||
MAX_CONCURRENT_BATCHES,
|
||||
async (batch, i) => {
|
||||
try {
|
||||
return await callEmbeddingAPI(batch, config)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to generate embeddings for batch ${i + 1}/${batches.length}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const allEmbeddings: number[][] = []
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i]
|
||||
const batchTokenCount = getTotalTokenCount(batch, embeddingModel)
|
||||
|
||||
logger.info(
|
||||
`Processing batch ${i + 1}/${batches.length}: ${batch.length} texts, ${batchTokenCount} tokens`
|
||||
)
|
||||
|
||||
try {
|
||||
const batchEmbeddings = await callEmbeddingAPI(batch, config)
|
||||
allEmbeddings.push(...batchEmbeddings)
|
||||
|
||||
logger.info(
|
||||
`Generated ${batchEmbeddings.length} embeddings for batch ${i + 1}/${batches.length}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to generate embeddings for batch ${i + 1}:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (i + 1 < batches.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
for (const batch of batchResults) {
|
||||
for (const emb of batch) {
|
||||
allEmbeddings.push(emb)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully generated ${allEmbeddings.length} embeddings total`)
|
||||
|
||||
return allEmbeddings
|
||||
}
|
||||
|
||||
|
||||
@@ -769,6 +769,80 @@ describe('buildTraceSpans', () => {
|
||||
expect(functionSpan?.status).toBe('error')
|
||||
expect((functionSpan?.output as { error?: string })?.error).toContain('Syntax Error')
|
||||
})
|
||||
|
||||
test('should remove childTraceSpans from output after integrating them as children', () => {
|
||||
const mockExecutionResult: ExecutionResult = {
|
||||
success: true,
|
||||
output: { result: 'parent output' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'workflow-1',
|
||||
blockName: 'Parent Workflow',
|
||||
blockType: 'workflow',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:05.000Z',
|
||||
durationMs: 5000,
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: { data: 'some result' },
|
||||
childTraceSpans: [
|
||||
{
|
||||
id: 'child-block-1',
|
||||
name: 'Supabase Query',
|
||||
type: 'supabase',
|
||||
blockId: 'supabase-1',
|
||||
duration: 2000,
|
||||
startTime: '2024-01-01T10:00:01.000Z',
|
||||
endTime: '2024-01-01T10:00:03.000Z',
|
||||
status: 'success' as const,
|
||||
output: {
|
||||
records: [
|
||||
{ id: 1, logo: 'data:image/png;base64,VeryLargeBase64StringHere...' },
|
||||
{ id: 2, logo: 'data:image/png;base64,AnotherLargeBase64StringHere...' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'child-block-2',
|
||||
name: 'Transform Data',
|
||||
type: 'function',
|
||||
blockId: 'function-1',
|
||||
duration: 500,
|
||||
startTime: '2024-01-01T10:00:03.000Z',
|
||||
endTime: '2024-01-01T10:00:03.500Z',
|
||||
status: 'success' as const,
|
||||
output: { transformed: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(mockExecutionResult)
|
||||
|
||||
expect(traceSpans).toHaveLength(1)
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.type).toBe('workflow')
|
||||
|
||||
expect(workflowSpan.children).toBeDefined()
|
||||
expect(workflowSpan.children).toHaveLength(2)
|
||||
expect(workflowSpan.children?.[0].name).toBe('Supabase Query')
|
||||
expect(workflowSpan.children?.[1].name).toBe('Transform Data')
|
||||
|
||||
expect(workflowSpan.output).toBeDefined()
|
||||
expect((workflowSpan.output as { childTraceSpans?: unknown }).childTraceSpans).toBeUndefined()
|
||||
|
||||
expect((workflowSpan.output as { success?: boolean }).success).toBe(true)
|
||||
expect((workflowSpan.output as { childWorkflowName?: string }).childWorkflowName).toBe(
|
||||
'Child Workflow'
|
||||
)
|
||||
expect((workflowSpan.output as { result?: { data: string } }).result).toEqual({
|
||||
data: 'some result',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripCustomToolPrefix', () => {
|
||||
|
||||
@@ -326,6 +326,11 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
const childTraceSpans = log.output.childTraceSpans as TraceSpan[]
|
||||
const flattenedChildren = flattenWorkflowChildren(childTraceSpans)
|
||||
span.children = mergeTraceSpanChildren(span.children || [], flattenedChildren)
|
||||
|
||||
const { childTraceSpans: _, ...cleanOutput } = span.output as {
|
||||
childTraceSpans?: TraceSpan[]
|
||||
} & Record<string, unknown>
|
||||
span.output = cleanOutput
|
||||
}
|
||||
|
||||
spanMap.set(spanId, span)
|
||||
|
||||
@@ -127,24 +127,6 @@ export function truncateToTokenLimit(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token count for multiple texts (for batching decisions)
|
||||
* Returns array of token counts in same order as input
|
||||
*/
|
||||
export function getTokenCountsForBatch(
|
||||
texts: string[],
|
||||
modelName = 'text-embedding-3-small'
|
||||
): number[] {
|
||||
return texts.map((text) => getAccurateTokenCount(text, modelName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total tokens across multiple texts
|
||||
*/
|
||||
export function getTotalTokenCount(texts: string[], modelName = 'text-embedding-3-small'): number {
|
||||
return texts.reduce((total, text) => total + getAccurateTokenCount(text, modelName), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch texts by token count to stay within API limits
|
||||
* Returns array of batches where each batch's total tokens <= maxTokensPerBatch
|
||||
|
||||
@@ -12,8 +12,6 @@ export {
|
||||
estimateOutputTokens,
|
||||
estimateTokenCount,
|
||||
getAccurateTokenCount,
|
||||
getTokenCountsForBatch,
|
||||
getTotalTokenCount,
|
||||
truncateToTokenLimit,
|
||||
} from '@/lib/tokenization/estimators'
|
||||
export { processStreamingBlockLog, processStreamingBlockLogs } from '@/lib/tokenization/streaming'
|
||||
|
||||
@@ -121,6 +121,34 @@ export async function handleProviderChallenges(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle provider-specific reachability tests that occur AFTER webhook lookup.
|
||||
*
|
||||
* @param webhook - The webhook record from the database
|
||||
* @param body - The parsed request body
|
||||
* @param requestId - Request ID for logging
|
||||
* @returns NextResponse if this is a verification request, null to continue normal flow
|
||||
*/
|
||||
export function handleProviderReachabilityTest(
|
||||
webhook: any,
|
||||
body: any,
|
||||
requestId: string
|
||||
): NextResponse | null {
|
||||
const provider = webhook?.provider
|
||||
|
||||
if (provider === 'grain') {
|
||||
const isVerificationRequest = !body || Object.keys(body).length === 0 || !body.type
|
||||
if (isVerificationRequest) {
|
||||
logger.info(
|
||||
`[${requestId}] Grain reachability test detected - returning 200 for webhook verification`
|
||||
)
|
||||
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function findWebhookAndWorkflow(
|
||||
options: WebhookProcessorOptions
|
||||
): Promise<{ webhook: any; workflow: any } | null> {
|
||||
|
||||
@@ -749,7 +749,6 @@ export async function formatWebhookInput(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for unknown Telegram update types
|
||||
logger.warn('Unknown Telegram update type', {
|
||||
updateId: body.update_id,
|
||||
bodyKeys: Object.keys(body || {}),
|
||||
@@ -778,7 +777,6 @@ export async function formatWebhookInput(
|
||||
|
||||
if (foundWebhook.provider === 'twilio_voice') {
|
||||
return {
|
||||
// Root-level properties matching trigger outputs for easy access
|
||||
callSid: body.CallSid,
|
||||
accountSid: body.AccountSid,
|
||||
from: body.From,
|
||||
@@ -792,8 +790,6 @@ export async function formatWebhookInput(
|
||||
speechResult: body.SpeechResult,
|
||||
recordingUrl: body.RecordingUrl,
|
||||
recordingSid: body.RecordingSid,
|
||||
|
||||
// Additional fields from Twilio payload
|
||||
called: body.Called,
|
||||
caller: body.Caller,
|
||||
toCity: body.ToCity,
|
||||
@@ -830,14 +826,48 @@ export async function formatWebhookInput(
|
||||
|
||||
if (foundWebhook.provider === 'gmail') {
|
||||
if (body && typeof body === 'object' && 'email' in body) {
|
||||
return body
|
||||
const email = body.email as Record<string, any>
|
||||
const timestamp = body.timestamp
|
||||
return {
|
||||
...email,
|
||||
email,
|
||||
...(timestamp !== undefined && { timestamp }),
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'gmail',
|
||||
path: foundWebhook.path,
|
||||
providerConfig: foundWebhook.providerConfig,
|
||||
payload: body,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
method: request.method,
|
||||
},
|
||||
},
|
||||
workflowId: foundWorkflow.id,
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'outlook') {
|
||||
if (body && typeof body === 'object' && 'email' in body) {
|
||||
return body
|
||||
const email = body.email as Record<string, any>
|
||||
const timestamp = body.timestamp
|
||||
return {
|
||||
...email,
|
||||
email,
|
||||
...(timestamp !== undefined && { timestamp }),
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'outlook',
|
||||
path: foundWebhook.path,
|
||||
providerConfig: foundWebhook.providerConfig,
|
||||
payload: body,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
method: request.method,
|
||||
},
|
||||
},
|
||||
workflowId: foundWorkflow.id,
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -926,19 +956,16 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
// Check if this is a Microsoft Graph change notification
|
||||
if (body?.value && Array.isArray(body.value) && body.value.length > 0) {
|
||||
return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request)
|
||||
}
|
||||
|
||||
// Microsoft Teams outgoing webhook - Teams sending data to us
|
||||
const messageText = body?.text || ''
|
||||
const messageId = body?.id || ''
|
||||
const timestamp = body?.timestamp || body?.localTimestamp || ''
|
||||
const from = body?.from || {}
|
||||
const conversation = body?.conversation || {}
|
||||
|
||||
// Construct the message object
|
||||
const messageObj = {
|
||||
raw: {
|
||||
attachments: body?.attachments || [],
|
||||
@@ -951,14 +978,12 @@ export async function formatWebhookInput(
|
||||
},
|
||||
}
|
||||
|
||||
// Construct the from object
|
||||
const fromObj = {
|
||||
id: from.id || '',
|
||||
name: from.name || '',
|
||||
aadObjectId: from.aadObjectId || '',
|
||||
}
|
||||
|
||||
// Construct the conversation object
|
||||
const conversationObj = {
|
||||
id: conversation.id || '',
|
||||
name: conversation.name || '',
|
||||
@@ -968,13 +993,11 @@ export async function formatWebhookInput(
|
||||
conversationType: conversation.conversationType || '',
|
||||
}
|
||||
|
||||
// Construct the activity object
|
||||
const activityObj = body || {}
|
||||
|
||||
return {
|
||||
input: messageText, // Primary workflow input - the message text
|
||||
input: messageText,
|
||||
|
||||
// Top-level properties for direct access with <microsoftteams.from.name> syntax
|
||||
from: fromObj,
|
||||
message: messageObj,
|
||||
activity: activityObj,
|
||||
@@ -995,11 +1018,9 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'slack') {
|
||||
// Slack input formatting logic - check for valid event
|
||||
const event = body?.event
|
||||
|
||||
if (event && body?.type === 'event_callback') {
|
||||
// Extract event text with fallbacks for different event types
|
||||
let input = ''
|
||||
|
||||
if (event.text) {
|
||||
@@ -1010,13 +1031,12 @@ export async function formatWebhookInput(
|
||||
input = 'Slack event received'
|
||||
}
|
||||
|
||||
// Create the event object for easier access
|
||||
const eventObj = {
|
||||
event_type: event.type || '',
|
||||
channel: event.channel || '',
|
||||
channel_name: '', // Could be resolved via additional API calls if needed
|
||||
channel_name: '',
|
||||
user: event.user || '',
|
||||
user_name: '', // Could be resolved via additional API calls if needed
|
||||
user_name: '',
|
||||
text: event.text || '',
|
||||
timestamp: event.ts || event.event_ts || '',
|
||||
team_id: body.team_id || event.team || '',
|
||||
@@ -1024,12 +1044,9 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
return {
|
||||
input, // Primary workflow input - the event content
|
||||
input,
|
||||
|
||||
// // // Top-level properties for backward compatibility with <blockName.event> syntax
|
||||
event: eventObj,
|
||||
|
||||
// Keep the nested structure for the new slack.event.text syntax
|
||||
slack: {
|
||||
event: eventObj,
|
||||
},
|
||||
@@ -1047,7 +1064,6 @@ export async function formatWebhookInput(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for unknown Slack event types
|
||||
logger.warn('Unknown Slack event type', {
|
||||
type: body?.type,
|
||||
hasEvent: !!body?.event,
|
||||
@@ -1283,9 +1299,7 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
return {
|
||||
// Expose raw GitHub payload at the root
|
||||
...body,
|
||||
// Include webhook metadata alongside
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'github',
|
||||
@@ -1364,10 +1378,7 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'linear') {
|
||||
// Linear webhook payload structure:
|
||||
// { action, type, webhookId, webhookTimestamp, organizationId, createdAt, actor, data, updatedFrom? }
|
||||
return {
|
||||
// Extract top-level fields from Linear payload
|
||||
action: body.action || '',
|
||||
type: body.type || '',
|
||||
webhookId: body.webhookId || '',
|
||||
@@ -1377,8 +1388,6 @@ export async function formatWebhookInput(
|
||||
actor: body.actor || null,
|
||||
data: body.data || null,
|
||||
updatedFrom: body.updatedFrom || null,
|
||||
|
||||
// Keep webhook metadata
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'linear',
|
||||
@@ -1393,7 +1402,6 @@ export async function formatWebhookInput(
|
||||
}
|
||||
}
|
||||
|
||||
// Jira webhook format
|
||||
if (foundWebhook.provider === 'jira') {
|
||||
const { extractIssueData, extractCommentData, extractWorklogData } = await import(
|
||||
'@/triggers/jira/utils'
|
||||
@@ -1445,7 +1453,6 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'calendly') {
|
||||
// Calendly webhook payload format matches the trigger outputs
|
||||
return {
|
||||
event: body.event,
|
||||
created_at: body.created_at,
|
||||
@@ -1466,9 +1473,7 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'circleback') {
|
||||
// Circleback webhook payload - meeting notes, action items, transcript
|
||||
return {
|
||||
// Top-level fields from Circleback payload
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
createdAt: body.createdAt,
|
||||
@@ -1482,10 +1487,7 @@ export async function formatWebhookInput(
|
||||
actionItems: body.actionItems || [],
|
||||
transcript: body.transcript || [],
|
||||
insights: body.insights || {},
|
||||
|
||||
// Full meeting object for convenience
|
||||
meeting: body,
|
||||
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'circleback',
|
||||
@@ -1501,9 +1503,7 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'grain') {
|
||||
// Grain webhook payload structure: { type, user_id, data: {...} }
|
||||
return {
|
||||
// Top-level fields from Grain payload
|
||||
type: body.type,
|
||||
user_id: body.user_id,
|
||||
data: body.data || {},
|
||||
|
||||
@@ -226,10 +226,27 @@ export function getBlockOutputs(
|
||||
}
|
||||
|
||||
if (blockType === 'human_in_the_loop') {
|
||||
// For human_in_the_loop, only expose url (inputFormat fields are only available after resume)
|
||||
return {
|
||||
const hitlOutputs: Record<string, any> = {
|
||||
url: { type: 'string', description: 'Resume UI URL' },
|
||||
resumeEndpoint: {
|
||||
type: 'string',
|
||||
description: 'Resume API endpoint URL for direct curl requests',
|
||||
},
|
||||
}
|
||||
|
||||
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
|
||||
|
||||
for (const field of normalizedInputFormat) {
|
||||
const fieldName = field?.name?.trim()
|
||||
if (!fieldName) continue
|
||||
|
||||
hitlOutputs[fieldName] = {
|
||||
type: (field?.type || 'any') as any,
|
||||
description: `Field from resume form`,
|
||||
}
|
||||
}
|
||||
|
||||
return hitlOutputs
|
||||
}
|
||||
|
||||
if (blockType === 'approval') {
|
||||
|
||||
@@ -161,6 +161,49 @@ function formatFieldName(fieldName: string): string {
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove malformed subBlocks from a block that may have been created by bugs.
|
||||
* This includes subBlocks with:
|
||||
* - Key "undefined" (caused by assigning to undefined key)
|
||||
* - Missing required `id` field
|
||||
* - Type "unknown" (indicates malformed data)
|
||||
*/
|
||||
function removeMalformedSubBlocks(block: any): void {
|
||||
if (!block.subBlocks) return
|
||||
|
||||
const keysToRemove: string[] = []
|
||||
|
||||
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
|
||||
// Flag subBlocks with invalid keys (literal "undefined" string)
|
||||
if (key === 'undefined') {
|
||||
keysToRemove.push(key)
|
||||
return
|
||||
}
|
||||
|
||||
// Flag subBlocks that are null or not objects
|
||||
if (!subBlock || typeof subBlock !== 'object') {
|
||||
keysToRemove.push(key)
|
||||
return
|
||||
}
|
||||
|
||||
// Flag subBlocks with type "unknown" (malformed data)
|
||||
if (subBlock.type === 'unknown') {
|
||||
keysToRemove.push(key)
|
||||
return
|
||||
}
|
||||
|
||||
// Flag subBlocks missing required id field
|
||||
if (!subBlock.id) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove the flagged keys
|
||||
keysToRemove.forEach((key) => {
|
||||
delete block.subBlocks[key]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize workflow state by removing all credentials and workspace-specific data
|
||||
* This is used for both template creation and workflow export to ensure consistency
|
||||
@@ -183,6 +226,9 @@ export function sanitizeWorkflowForSharing(
|
||||
Object.values(sanitized.blocks).forEach((block: any) => {
|
||||
if (!block?.type) return
|
||||
|
||||
// First, remove any malformed subBlocks that may have been created by bugs
|
||||
removeMalformedSubBlocks(block)
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
|
||||
// Process subBlocks with config
|
||||
|
||||
@@ -538,15 +538,15 @@ export class PauseResumeManager {
|
||||
|
||||
mergedOutput.resume = mergedOutput.resume ?? mergedResponse.resume
|
||||
|
||||
// Preserve url from resume links (apiUrl hidden from output)
|
||||
// Preserve url and resumeEndpoint from resume links
|
||||
const resumeLinks = mergedOutput.resume ?? mergedResponse.resume
|
||||
if (resumeLinks && typeof resumeLinks === 'object') {
|
||||
if (resumeLinks.uiUrl) {
|
||||
mergedOutput.url = resumeLinks.uiUrl
|
||||
}
|
||||
// if (resumeLinks.apiUrl) {
|
||||
// mergedOutput.apiUrl = resumeLinks.apiUrl
|
||||
// } // Hidden from output
|
||||
if (resumeLinks.apiUrl) {
|
||||
mergedOutput.resumeEndpoint = resumeLinks.apiUrl
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(submissionPayload)) {
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"onedollarstats": "0.0.10",
|
||||
"openai": "^4.91.1",
|
||||
"papaparse": "5.5.3",
|
||||
"pdf-lib": "1.17.1",
|
||||
"postgres": "^3.4.5",
|
||||
"posthog-js": "1.268.9",
|
||||
"posthog-node": "5.9.2",
|
||||
|
||||
@@ -5,9 +5,14 @@ import type { WorkflowState } from '../workflow/types'
|
||||
const logger = createLogger('WorkflowJsonImporter')
|
||||
|
||||
/**
|
||||
* Normalize subblock values by converting empty strings to null.
|
||||
* Normalize subblock values by converting empty strings to null and filtering out invalid subblocks.
|
||||
* This provides backwards compatibility for workflows exported before the null sanitization fix,
|
||||
* preventing Zod validation errors like "Expected array, received string".
|
||||
*
|
||||
* Also filters out malformed subBlocks that may have been created by bugs in previous exports:
|
||||
* - SubBlocks with key "undefined" (caused by assigning to undefined key)
|
||||
* - SubBlocks missing required fields like `id`
|
||||
* - SubBlocks with `type: "unknown"` (indicates malformed data)
|
||||
*/
|
||||
function normalizeSubblockValues(blocks: Record<string, any>): Record<string, any> {
|
||||
const normalizedBlocks: Record<string, any> = {}
|
||||
@@ -19,6 +24,34 @@ function normalizeSubblockValues(blocks: Record<string, any>): Record<string, an
|
||||
const normalizedSubBlocks: Record<string, any> = {}
|
||||
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
|
||||
// Skip subBlocks with invalid keys (literal "undefined" string)
|
||||
if (subBlockId === 'undefined') {
|
||||
logger.warn(`Skipping malformed subBlock with key "undefined" in block ${blockId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip subBlocks that are null or not objects
|
||||
if (!subBlock || typeof subBlock !== 'object') {
|
||||
logger.warn(`Skipping invalid subBlock ${subBlockId} in block ${blockId}: not an object`)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip subBlocks with type "unknown" (malformed data)
|
||||
if (subBlock.type === 'unknown') {
|
||||
logger.warn(
|
||||
`Skipping malformed subBlock ${subBlockId} in block ${blockId}: type is "unknown"`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip subBlocks missing required id field
|
||||
if (!subBlock.id) {
|
||||
logger.warn(
|
||||
`Skipping malformed subBlock ${subBlockId} in block ${blockId}: missing id field`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedSubBlock = { ...subBlock }
|
||||
|
||||
// Convert empty strings to null for consistency
|
||||
|
||||
@@ -90,6 +90,17 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
|
||||
transformResponse: async (response: Response): Promise<CodeExecutionOutput> => {
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
result: null,
|
||||
stdout: result.output?.stdout || '',
|
||||
},
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { requestTool } from './request'
|
||||
import { webhookRequestTool } from './webhook_request'
|
||||
|
||||
export const httpRequestTool = requestTool
|
||||
export { webhookRequestTool }
|
||||
|
||||
@@ -70,7 +70,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
return allHeaders
|
||||
},
|
||||
|
||||
body: (params: RequestParams) => {
|
||||
body: ((params: RequestParams) => {
|
||||
if (params.formData) {
|
||||
const formData = new FormData()
|
||||
Object.entries(params.formData).forEach(([key, value]) => {
|
||||
@@ -90,7 +90,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
) {
|
||||
// Convert JSON object to URL-encoded string
|
||||
const urlencoded = new URLSearchParams()
|
||||
Object.entries(params.body).forEach(([key, value]) => {
|
||||
Object.entries(params.body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlencoded.append(key, String(value))
|
||||
}
|
||||
@@ -98,11 +98,11 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
return urlencoded.toString()
|
||||
}
|
||||
|
||||
return params.body
|
||||
return params.body as Record<string, any>
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
}) as (params: RequestParams) => Record<string, any> | string | FormData | undefined,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user