Compare commits

..

20 Commits

Author SHA1 Message Date
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
d248557042 fix(settings): added isHosted gate to access homepage from settings, fixed context menu options (#2694)
* fix(settings): added isHosted gate to access homepage from settings, fixed context menu options

* stronger typing
2026-01-06 14:22:48 -08:00
Siddharth Ganesan
8215a819e5 improvement(hitl): add webhook notification and resume, add webhook block (#2673)
* Add api blcok as tool

* Add webhook block

* Hitl v1

* Cleanup

* Fix

* Update names for fields in hitl

* Fix hitl tag dropdown

* Update hitl dashboard

* Lint
2026-01-06 13:58:44 -08:00
Waleed
155f544ce8 feat(terminal): added terminal context menu (#2692) 2026-01-06 13:57:04 -08:00
Waleed
22f949a41c fix(condition): added success check on condition block processor, fixed terminal preventDefault copy bug (#2691) 2026-01-06 12:52:40 -08:00
Waleed
f9aef6ae22 feat(i18n): update translations (#2690)
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2026-01-06 12:42:19 -08:00
Waleed
46b04a964d feat(supabase): added ability so select certain rows in supabase tools (#2689)
* feat(supabase): added ability so select certain rows in supabase tools

* ack PR comments
2026-01-06 12:21:08 -08:00
Waleed
964b40de45 fix(traces): remove child trace spans from workflow block after being merged with parent output (#2688) 2026-01-05 20:34:45 -08:00
Waleed
75aca00b6e improvement(kb): optimize processes, add more robust fallbacks for large file ops (#2684)
* improvement(kb): optimize processes, add more robust fallbacks for large file ops

* stronger typing

* comments cleanup

* ack PR comments

* upgraded turborepo

* ack more PR comments

* fix failing test

* moved doc update inside tx for embeddings chunks upload

* ack more PR comments
2026-01-05 20:26:16 -08:00
Vikhyath Mondreti
d25084e05d fix(child-workflow): hosted api key resolution (#2687) 2026-01-05 17:41:58 -08:00
Adam Gough
445932c1c8 fix(grain): fixed output and dropdown (#2685)
* fixed output and dropdown

* changed payload

* removed comments
2026-01-05 17:05:45 -08:00
Waleed
cc3f565d5e fix(webhook): strip extraneous fields from trigger processing (#2686) 2026-01-05 16:19:49 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
0977ed228f improvement(kb): add configurable concurrency to chunks processing, sped up 22x for large docs (#2681) 2026-01-05 00:29:31 -08:00
Waleed
ed6b9c0c4a fix(kb): fix styling inconsistencies, add rename capability for documents, added search preview (#2680) 2026-01-04 23:47:54 -08:00
Adam Gough
86bcdcf0d3 fix(grain): save before deploying workflow (#2678)
* save before deployment fix

* moved to helper

* removed comment
2026-01-04 12:41:33 -08:00
Vikhyath Mondreti
ac942416de fix(kalshi): remove synthetically constructed outputs (#2677)
* fix(kalshi): remove synthetically constructed outputs

* fix api interface
2026-01-03 17:40:55 -08:00
Emir Karabeg
195e0e8e3f feat(popover): sections; improvement: tooltip, popover; fix(notifications): loading content (#2676) 2026-01-03 16:51:24 -08:00
Siddharth Ganesan
1673ef98ac fix(variables): fix variables block parsing error for json (#2675) 2026-01-03 14:42:39 -08:00
Siddharth Ganesan
356b473dc3 fix(import): fix missing blocks in import if undefined keys exist (#2674) 2026-01-03 14:37:59 -08:00
107 changed files with 3390 additions and 2113 deletions

View File

@@ -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`

View File

@@ -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

View File

@@ -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`

View File

@@ -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 |

View File

@@ -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`

View File

@@ -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 |

View File

@@ -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`

View File

@@ -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 |

View File

@@ -121,10 +121,8 @@ Kalshiからアカウント残高とポートフォリオ価値を取得
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `balance` | number | セント単位のアカウント残高 |
| `portfolioValue` | number | セント単位のポートフォリオ価値 |
| `balanceDollars` | number | ドル単位のアカウント残高 |
| `portfolioValueDollars` | number | ドル単位のポートフォリオ価値 |
| `balance` | number | アカウント残高(セント単位) |
| `portfolioValue` | number | ポートフォリオ価値(セント単位) |
### `kalshi_get_positions`

View File

@@ -49,7 +49,8 @@ Supabaseテーブルからデータを照会する
| --------- | ---- | -------- | ----------- |
| `projectId` | string | はい | あなたのSupabaseプロジェクトIDjdrkgepadsdopsntdlom |
| `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プロジェクトIDjdrkgepadsdopsntdlom |
| `table` | string | はい | クエリするSupabaseテーブルの名前 |
| `schema` | string | いいえ | クエリするデータベーススキーマデフォルトpublic。他のスキーマのテーブルにアクセスする場合に使用します。 |
| `schema` | string | いいえ | クエリ元のデータベーススキーマデフォルトpublic。他のスキーマのテーブルにアクセスする場合に使用します。 |
| `select` | string | いいえ | 返す列(カンマ区切り)。デフォルトは*(すべての列) |
| `filter` | string | はい | 特定の行を見つけるためのPostgRESTフィルター"id=eq.123" |
| `apiKey` | string | はい | あなたのSupabaseサービスロールシークレットキー |

View File

@@ -123,8 +123,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| --------- | ---- | ----------- |
| `balance` | number | 账户余额(以分为单位) |
| `portfolioValue` | number | 投资组合价值(以分为单位) |
| `balanceDollars` | number | 账户余额(以美元为单位) |
| `portfolioValueDollars` | number | 投资组合价值(以美元为单位) |
### `kalshi_get_positions`

View File

@@ -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 服务角色密钥 |
#### 输出

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 })

View File

@@ -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)

View File

@@ -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>

View File

@@ -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}

View File

@@ -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={

View File

@@ -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}

View File

@@ -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}

View File

@@ -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'

View File

@@ -0,0 +1 @@
export { RenameDocumentModal } from './rename-document-modal'

View File

@@ -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>
)
}

View File

@@ -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}

View File

@@ -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={() => {

View File

@@ -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

View File

@@ -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>
)
})}

View File

@@ -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>
)

View File

@@ -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={() => {

View File

@@ -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>

View File

@@ -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>
)
})}

View File

@@ -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') &&

View File

@@ -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) => {

View File

@@ -1 +1,3 @@
export { LogRowContextMenu } from './log-row-context-menu'
export { OutputContextMenu } from './output-context-menu'
export { PrettierOutput } from './prettier-output'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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}
/>
</>
)
}

View File

@@ -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>

View File

@@ -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()

View File

@@ -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 */}

View File

@@ -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={() => {

View File

@@ -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] ? (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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: [

View File

@@ -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',
},
},
}

View File

@@ -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' },

View 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' },
},
}

View File

@@ -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,

View File

@@ -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

View File

@@ -57,6 +57,8 @@ export {
type PopoverBackButtonProps,
PopoverContent,
type PopoverContentProps,
PopoverDivider,
type PopoverDividerProps,
PopoverFolder,
type PopoverFolderProps,
PopoverItem,

View File

@@ -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 }

View File

@@ -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>
))

View File

@@ -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>

View File

@@ -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,

View File

@@ -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)) {

View File

@@ -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({})
})

View File

@@ -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

View File

@@ -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,
},
})

View File

@@ -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,
})
}

View File

@@ -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,
}
)

View File

@@ -16,7 +16,7 @@ interface HeaderInfo {
interface Frontmatter {
title?: string
description?: string
[key: string]: any
[key: string]: unknown
}
const logger = createLogger('DocsChunker')

View File

@@ -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[] = []

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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}`)
}
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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])
}
})

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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

View File

@@ -12,8 +12,6 @@ export {
estimateOutputTokens,
estimateTokenCount,
getAccurateTokenCount,
getTokenCountsForBatch,
getTotalTokenCount,
truncateToTokenLimit,
} from '@/lib/tokenization/estimators'
export { processStreamingBlockLog, processStreamingBlockLogs } from '@/lib/tokenization/streaming'

View File

@@ -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> {

View File

@@ -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 || {},

View File

@@ -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') {

View File

@@ -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

View File

@@ -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)) {

View File

@@ -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",

View File

@@ -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

View File

@@ -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: {

View File

@@ -1,3 +1,5 @@
import { requestTool } from './request'
import { webhookRequestTool } from './webhook_request'
export const httpRequestTool = requestTool
export { webhookRequestTool }

View File

@@ -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>
}

View 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',
},
},
}

View File

@@ -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' },
},
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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
}

View File

@@ -17,7 +17,7 @@ export default defineConfig({
build: {
extensions: [
additionalPackages({
packages: ['unpdf'],
packages: ['unpdf', 'pdf-lib'],
}),
],
},

View File

@@ -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',

View File

@@ -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