mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-13 17:08:01 -05:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1025,7 +1025,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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -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'
|
||||
@@ -280,6 +281,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
wealthbox: WealthboxBlock,
|
||||
webflow: WebflowBlock,
|
||||
webhook: WebhookBlock,
|
||||
webhook_request: WebhookRequestBlock,
|
||||
whatsapp: WhatsAppBlock,
|
||||
wikipedia: WikipediaBlock,
|
||||
wordpress: WordPressBlock,
|
||||
|
||||
@@ -217,6 +217,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface RequestParams {
|
||||
url: string
|
||||
method?: HttpMethod
|
||||
headers?: TableRow[]
|
||||
body?: any
|
||||
body?: unknown
|
||||
params?: TableRow[]
|
||||
pathParams?: Record<string, string>
|
||||
formData?: Record<string, string | Blob>
|
||||
@@ -12,8 +12,15 @@ export interface RequestParams {
|
||||
|
||||
export interface RequestResponse extends ToolResponse {
|
||||
output: {
|
||||
data: any
|
||||
data: unknown
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebhookRequestParams {
|
||||
url: string
|
||||
body?: unknown
|
||||
secret?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
130
apps/sim/tools/http/webhook_request.ts
Normal file
130
apps/sim/tools/http/webhook_request.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { RequestResponse, WebhookRequestParams } from './types'
|
||||
|
||||
/**
|
||||
* Generates HMAC-SHA256 signature for webhook payload
|
||||
*/
|
||||
function generateSignature(secret: string, timestamp: number, body: string): string {
|
||||
const signatureBase = `${timestamp}.${body}`
|
||||
return createHmac('sha256', secret).update(signatureBase).digest('hex')
|
||||
}
|
||||
|
||||
export const webhookRequestTool: ToolConfig<WebhookRequestParams, RequestResponse> = {
|
||||
id: 'webhook_request',
|
||||
name: 'Webhook Request',
|
||||
description: 'Send a webhook request with automatic headers and optional HMAC signing',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
url: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The webhook URL to send the request to',
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
description: 'JSON payload to send',
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
description: 'Optional secret for HMAC-SHA256 signature',
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Additional headers to include',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: WebhookRequestParams) => params.url,
|
||||
|
||||
method: () => 'POST',
|
||||
|
||||
headers: (params: WebhookRequestParams) => {
|
||||
const timestamp = Date.now()
|
||||
const deliveryId = uuidv4()
|
||||
|
||||
// Start with webhook-specific headers
|
||||
const webhookHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Timestamp': timestamp.toString(),
|
||||
'X-Delivery-ID': deliveryId,
|
||||
'Idempotency-Key': deliveryId,
|
||||
}
|
||||
|
||||
// Add signature if secret is provided
|
||||
if (params.secret) {
|
||||
const bodyString =
|
||||
typeof params.body === 'string' ? params.body : JSON.stringify(params.body || {})
|
||||
const signature = generateSignature(params.secret, timestamp, bodyString)
|
||||
webhookHeaders['X-Webhook-Signature'] = `t=${timestamp},v1=${signature}`
|
||||
}
|
||||
|
||||
// Merge with user-provided headers (user headers take precedence)
|
||||
const userHeaders = params.headers || {}
|
||||
|
||||
return { ...webhookHeaders, ...userHeaders }
|
||||
},
|
||||
|
||||
body: (params: WebhookRequestParams) => params.body,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key] = value
|
||||
})
|
||||
|
||||
const data = await (contentType.includes('application/json')
|
||||
? response.json()
|
||||
: response.text())
|
||||
|
||||
// Check if this is a proxy response
|
||||
if (
|
||||
contentType.includes('application/json') &&
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
data.data !== undefined &&
|
||||
data.status !== undefined
|
||||
) {
|
||||
return {
|
||||
success: data.success,
|
||||
output: {
|
||||
data: data.data,
|
||||
status: data.status,
|
||||
headers: data.headers || {},
|
||||
},
|
||||
error: data.success ? undefined : data.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
output: {
|
||||
data,
|
||||
status: response.status,
|
||||
headers,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Response data from the webhook endpoint',
|
||||
},
|
||||
status: {
|
||||
type: 'number',
|
||||
description: 'HTTP status code',
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Response headers',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -8,9 +8,7 @@ export interface KalshiGetBalanceResponse {
|
||||
success: boolean
|
||||
output: {
|
||||
balance: number // In cents
|
||||
portfolioValue?: number // In cents
|
||||
balanceDollars: number // Converted to dollars
|
||||
portfolioValueDollars?: number // Converted to dollars
|
||||
portfolioValue: number // In cents
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,16 +49,14 @@ export const kalshiGetBalanceTool: ToolConfig<KalshiGetBalanceParams, KalshiGetB
|
||||
handleKalshiError(data, response.status, 'get_balance')
|
||||
}
|
||||
|
||||
const balance = data.balance || 0
|
||||
const portfolioValue = data.portfolio_value
|
||||
const balance = data.balance ?? 0
|
||||
const portfolioValue = data.portfolio_value ?? 0
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
balance,
|
||||
portfolioValue,
|
||||
balanceDollars: balance / 100,
|
||||
portfolioValueDollars: portfolioValue ? portfolioValue / 100 : undefined,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -68,7 +64,5 @@ export const kalshiGetBalanceTool: ToolConfig<KalshiGetBalanceParams, KalshiGetB
|
||||
outputs: {
|
||||
balance: { type: 'number', description: 'Account balance in cents' },
|
||||
portfolioValue: { type: 'number', description: 'Portfolio value in cents' },
|
||||
balanceDollars: { type: 'number', description: 'Account balance in dollars' },
|
||||
portfolioValueDollars: { type: 'number', description: 'Portfolio value in dollars' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface KalshiEvent {
|
||||
// Balance type
|
||||
export interface KalshiBalance {
|
||||
balance: number // In cents
|
||||
portfolio_value?: number // In cents
|
||||
portfolio_value: number // In cents
|
||||
}
|
||||
|
||||
// Position type
|
||||
|
||||
@@ -376,7 +376,7 @@ import {
|
||||
greptileStatusTool,
|
||||
} from '@/tools/greptile'
|
||||
import { guardrailsValidateTool } from '@/tools/guardrails'
|
||||
import { httpRequestTool } from '@/tools/http'
|
||||
import { httpRequestTool, webhookRequestTool } from '@/tools/http'
|
||||
import {
|
||||
hubspotCreateCompanyTool,
|
||||
hubspotCreateContactTool,
|
||||
@@ -1415,6 +1415,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
browser_use_run_task: browserUseRunTaskTool,
|
||||
openai_embeddings: openAIEmbeddingsTool,
|
||||
http_request: httpRequestTool,
|
||||
webhook_request: webhookRequestTool,
|
||||
huggingface_chat: huggingfaceChatTool,
|
||||
llm_chat: llmChatTool,
|
||||
function_execute: functionExecuteTool,
|
||||
|
||||
@@ -27,6 +27,12 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
|
||||
description:
|
||||
'Database schema to query from (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
select: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Columns to return (comma-separated). Defaults to * (all columns)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -44,7 +50,8 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
|
||||
request: {
|
||||
url: (params) => {
|
||||
// Construct the URL for the Supabase REST API
|
||||
let url = `https://${params.projectId}.supabase.co/rest/v1/${params.table}?select=*`
|
||||
const selectColumns = params.select?.trim() || '*'
|
||||
let url = `https://${params.projectId}.supabase.co/rest/v1/${params.table}?select=${encodeURIComponent(selectColumns)}`
|
||||
|
||||
// Add filters (required for get_row) - using PostgREST syntax
|
||||
if (params.filter?.trim()) {
|
||||
|
||||
@@ -27,6 +27,12 @@ export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> =
|
||||
description:
|
||||
'Database schema to query from (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
select: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Columns to return (comma-separated). Defaults to * (all columns)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -56,7 +62,8 @@ export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> =
|
||||
request: {
|
||||
url: (params) => {
|
||||
// Construct the URL for the Supabase REST API
|
||||
let url = `https://${params.projectId}.supabase.co/rest/v1/${params.table}?select=*`
|
||||
const selectColumns = params.select?.trim() || '*'
|
||||
let url = `https://${params.projectId}.supabase.co/rest/v1/${params.table}?select=${encodeURIComponent(selectColumns)}`
|
||||
|
||||
// Add filters if provided - using PostgREST syntax
|
||||
if (params.filter?.trim()) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface SupabaseQueryParams {
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
select?: string
|
||||
filter?: string
|
||||
orderBy?: string
|
||||
limit?: number
|
||||
@@ -23,6 +24,7 @@ export interface SupabaseGetRowParams {
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
select?: string
|
||||
filter: string
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
build: {
|
||||
extensions: [
|
||||
additionalPackages({
|
||||
packages: ['unpdf'],
|
||||
packages: ['unpdf', 'pdf-lib'],
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GrainIcon } from '@/components/icons'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import { buildHighlightOutputs, grainSetupInstructions } from './utils'
|
||||
import { buildHighlightOutputs, grainSetupInstructions, grainTriggerOptions } from './utils'
|
||||
|
||||
export const grainHighlightCreatedTrigger: TriggerConfig = {
|
||||
id: 'grain_highlight_created',
|
||||
@@ -11,6 +11,15 @@ export const grainHighlightCreatedTrigger: TriggerConfig = {
|
||||
icon: GrainIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'selectedTriggerId',
|
||||
title: 'Trigger Type',
|
||||
type: 'dropdown',
|
||||
mode: 'trigger',
|
||||
options: grainTriggerOptions,
|
||||
value: () => 'grain_highlight_created',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
@@ -25,42 +34,6 @@ export const grainHighlightCreatedTrigger: TriggerConfig = {
|
||||
value: 'grain_highlight_created',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeHighlights',
|
||||
title: 'Include Highlights',
|
||||
type: 'switch',
|
||||
description: 'Include highlights/clips in webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'grain_highlight_created',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeParticipants',
|
||||
title: 'Include Participants',
|
||||
type: 'switch',
|
||||
description: 'Include participant list in webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'grain_highlight_created',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeAiSummary',
|
||||
title: 'Include AI Summary',
|
||||
type: 'switch',
|
||||
description: 'Include AI-generated summary in webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'grain_highlight_created',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GrainIcon } from '@/components/icons'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import { buildHighlightOutputs, grainSetupInstructions } from './utils'
|
||||
import { buildHighlightOutputs, grainSetupInstructions, grainTriggerOptions } from './utils'
|
||||
|
||||
export const grainHighlightUpdatedTrigger: TriggerConfig = {
|
||||
id: 'grain_highlight_updated',
|
||||
@@ -11,6 +11,15 @@ export const grainHighlightUpdatedTrigger: TriggerConfig = {
|
||||
icon: GrainIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'selectedTriggerId',
|
||||
title: 'Trigger Type',
|
||||
type: 'dropdown',
|
||||
mode: 'trigger',
|
||||
options: grainTriggerOptions,
|
||||
value: () => 'grain_highlight_updated',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
@@ -25,42 +34,6 @@ export const grainHighlightUpdatedTrigger: TriggerConfig = {
|
||||
value: 'grain_highlight_updated',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeHighlights',
|
||||
title: 'Include Highlights',
|
||||
type: 'switch',
|
||||
description: 'Include highlights/clips in webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'grain_highlight_updated',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeParticipants',
|
||||
title: 'Include Participants',
|
||||
type: 'switch',
|
||||
description: 'Include participant list in webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'grain_highlight_updated',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeAiSummary',
|
||||
title: 'Include AI Summary',
|
||||
type: 'switch',
|
||||
description: 'Include AI-generated summary in webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'grain_highlight_updated',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user