mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
20 Commits
v0.5.48
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97839473c5 | ||
|
|
c369e04189 | ||
|
|
27c7bcf71d | ||
|
|
0bc8c0e1bc | ||
|
|
0977ed228f | ||
|
|
ed6b9c0c4a | ||
|
|
86bcdcf0d3 | ||
|
|
ac942416de | ||
|
|
195e0e8e3f | ||
|
|
1673ef98ac | ||
|
|
356b473dc3 | ||
|
|
8d15219c12 | ||
|
|
c3adcf315b | ||
|
|
4df5d56ac5 | ||
|
|
7515809df0 | ||
|
|
385e93f4bb | ||
|
|
096af4fdfa | ||
|
|
dc3de95c39 | ||
|
|
79be435918 | ||
|
|
852562cfdd |
@@ -30,6 +30,18 @@ import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/component
|
||||
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
|
||||
```
|
||||
|
||||
## No Re-exports
|
||||
|
||||
Do not re-export from non-barrel files. Import directly from the source.
|
||||
|
||||
```typescript
|
||||
// ✓ Good - import from where it's declared
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
// ✗ Bad - re-exporting in utils.ts then importing from there
|
||||
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
|
||||
```
|
||||
|
||||
## Import Order
|
||||
|
||||
1. React/core libraries
|
||||
|
||||
@@ -9,7 +9,7 @@ globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.css"]
|
||||
|
||||
1. **No inline styles** - Use Tailwind classes
|
||||
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
|
||||
3. **Exact values** - `text-[14px]`, `h-[25px]`
|
||||
3. **Exact values** - `text-[14px]`, `h-[26px]`
|
||||
4. **Transitions** - `transition-colors` for interactive states
|
||||
|
||||
## Conditional Classes
|
||||
|
||||
@@ -52,7 +52,7 @@ import { useWorkflowStore } from '@/stores/workflows/store'
|
||||
import { useWorkflowStore } from '../../../stores/workflows/store'
|
||||
```
|
||||
|
||||
Use barrel exports (`index.ts`) when a folder has 3+ exports.
|
||||
Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.
|
||||
|
||||
### Import Order
|
||||
1. React/core libraries
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -187,7 +187,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Sim Studio, Inc.
|
||||
Copyright 2026 Sim Studio, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
2
NOTICE
2
NOTICE
@@ -1,4 +1,4 @@
|
||||
Sim Studio
|
||||
Copyright 2025 Sim Studio
|
||||
Copyright 2026 Sim Studio
|
||||
|
||||
This product includes software developed for the Sim project.
|
||||
File diff suppressed because one or more lines are too long
@@ -58,6 +58,7 @@ import {
|
||||
LinkupIcon,
|
||||
MailchimpIcon,
|
||||
MailgunIcon,
|
||||
MailServerIcon,
|
||||
Mem0Icon,
|
||||
MicrosoftExcelIcon,
|
||||
MicrosoftOneDriveIcon,
|
||||
@@ -165,6 +166,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
image_generator: ImageIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
intercom: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
|
||||
36
apps/docs/content/docs/de/tools/imap.mdx
Normal file
36
apps/docs/content/docs/de/tools/imap.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: IMAP-E-Mail
|
||||
description: Workflows auslösen, wenn neue E-Mails über IMAP eintreffen
|
||||
(funktioniert mit jedem E-Mail-Anbieter)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="imap"
|
||||
color="#6366F1"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
Der IMAP-E-Mail-Trigger ermöglicht es Ihren Sim-Workflows, automatisch zu starten, sobald eine neue E-Mail in einem Postfach empfangen wird, das das IMAP-Protokoll unterstützt. Dies funktioniert mit Gmail, Outlook, Yahoo und den meisten anderen E-Mail-Anbietern.
|
||||
|
||||
Mit dem IMAP-Trigger können Sie:
|
||||
|
||||
- **E-Mail-Verarbeitung automatisieren**: Starten Sie Workflows in Echtzeit, wenn neue Nachrichten in Ihrem Posteingang eintreffen.
|
||||
- **Nach Absender, Betreff oder Ordner filtern**: Konfigurieren Sie Ihren Trigger so, dass er nur auf E-Mails reagiert, die bestimmte Bedingungen erfüllen.
|
||||
- **Anhänge extrahieren und verarbeiten**: Laden Sie Dateianhänge automatisch herunter und verwenden Sie sie in Ihren automatisierten Abläufen.
|
||||
- **E-Mail-Inhalte parsen und verwenden**: Greifen Sie auf Betreff, Absender, Empfänger, vollständigen Text und andere Metadaten in nachfolgenden Workflow-Schritten zu.
|
||||
- **Mit jedem E-Mail-Anbieter integrieren**: Funktioniert mit jedem Dienst, der standardmäßigen IMAP-Zugriff bietet, ohne Vendor-Lock-in.
|
||||
- **Bei ungelesenen, markierten oder benutzerdefinierten Kriterien auslösen**: Richten Sie erweiterte Filter für die Arten von E-Mails ein, die Ihre Workflows starten.
|
||||
|
||||
Mit Sim gibt Ihnen die IMAP-Integration die Möglichkeit, E-Mails in eine handlungsfähige Automatisierungsquelle zu verwandeln. Reagieren Sie auf Kundenanfragen, verarbeiten Sie Benachrichtigungen, starten Sie Daten-Pipelines und mehr – direkt aus Ihrem E-Mail-Posteingang, ohne manuelles Eingreifen.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Nutzungsanleitung
|
||||
|
||||
Verbinden Sie sich über das IMAP-Protokoll mit jedem E-Mail-Server, um Workflows auszulösen, wenn neue E-Mails empfangen werden. Unterstützt Gmail, Outlook, Yahoo und jeden anderen IMAP-kompatiblen E-Mail-Anbieter.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Kategorie: `triggers`
|
||||
- Typ: `imap`
|
||||
40
apps/docs/content/docs/en/tools/imap.mdx
Normal file
40
apps/docs/content/docs/en/tools/imap.mdx
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: IMAP Email
|
||||
description: Trigger workflows when new emails arrive via IMAP (works with any email provider)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="imap"
|
||||
color="#6366F1"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The IMAP Email trigger allows your Sim workflows to start automatically whenever a new email is received in any mailbox that supports the IMAP protocol. This works with Gmail, Outlook, Yahoo, and most other email providers.
|
||||
|
||||
With the IMAP trigger, you can:
|
||||
|
||||
- **Automate email processing**: Start workflows in real time when new messages arrive in your inbox.
|
||||
- **Filter by sender, subject, or folder**: Configure your trigger to react only to emails that match certain conditions.
|
||||
- **Extract and process attachments**: Automatically download and use file attachments in your automated flows.
|
||||
- **Parse and use email content**: Access the subject, sender, recipients, full body, and other metadata in downstream workflow steps.
|
||||
- **Integrate with any email provider**: Works with any service that provides standard IMAP access, without vendor lock-in.
|
||||
- **Trigger on unread, flagged, or custom criteria**: Set up advanced filters for the kinds of emails that start your workflows.
|
||||
|
||||
With Sim, the IMAP integration gives you the power to turn email into an actionable source of automation. Respond to customer inquiries, process notifications, kick off data pipelines, and more—directly from your email inbox, with no manual intervention.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to any email server via IMAP protocol to trigger workflows when new emails are received. Supports Gmail, Outlook, Yahoo, and any other IMAP-compatible email provider.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `triggers`
|
||||
- Type: `imap`
|
||||
@@ -42,6 +42,7 @@
|
||||
"huggingface",
|
||||
"hunter",
|
||||
"image_generator",
|
||||
"imap",
|
||||
"incidentio",
|
||||
"intercom",
|
||||
"jina",
|
||||
|
||||
36
apps/docs/content/docs/es/tools/imap.mdx
Normal file
36
apps/docs/content/docs/es/tools/imap.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Correo electrónico IMAP
|
||||
description: Activa flujos de trabajo cuando lleguen nuevos correos electrónicos
|
||||
a través de IMAP (funciona con cualquier proveedor de correo electrónico)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="imap"
|
||||
color="#6366F1"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
El activador de correo electrónico IMAP permite que tus flujos de trabajo de Sim se inicien automáticamente cada vez que se reciba un nuevo correo electrónico en cualquier buzón que admita el protocolo IMAP. Esto funciona con Gmail, Outlook, Yahoo y la mayoría de los demás proveedores de correo electrónico.
|
||||
|
||||
Con el activador IMAP, puedes:
|
||||
|
||||
- **Automatizar el procesamiento de correos electrónicos**: inicia flujos de trabajo en tiempo real cuando lleguen nuevos mensajes a tu bandeja de entrada.
|
||||
- **Filtrar por remitente, asunto o carpeta**: configura tu activador para que reaccione solo a los correos electrónicos que cumplan ciertas condiciones.
|
||||
- **Extraer y procesar archivos adjuntos**: descarga y utiliza automáticamente archivos adjuntos en tus flujos automatizados.
|
||||
- **Analizar y utilizar el contenido del correo electrónico**: accede al asunto, remitente, destinatarios, cuerpo completo y otros metadatos en los pasos posteriores del flujo de trabajo.
|
||||
- **Integrar con cualquier proveedor de correo electrónico**: funciona con cualquier servicio que proporcione acceso IMAP estándar, sin dependencia de proveedores.
|
||||
- **Activar según criterios de no leído, marcado o personalizados**: configura filtros avanzados para los tipos de correos electrónicos que inician tus flujos de trabajo.
|
||||
|
||||
Con Sim, la integración IMAP te brinda el poder de convertir el correo electrónico en una fuente de automatización procesable. Responde a consultas de clientes, procesa notificaciones, inicia pipelines de datos y más, directamente desde tu bandeja de entrada de correo electrónico, sin intervención manual.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Instrucciones de uso
|
||||
|
||||
Conéctate a cualquier servidor de correo electrónico a través del protocolo IMAP para activar flujos de trabajo cuando se reciban nuevos correos electrónicos. Compatible con Gmail, Outlook, Yahoo y cualquier otro proveedor de correo electrónico compatible con IMAP.
|
||||
|
||||
## Notas
|
||||
|
||||
- Categoría: `triggers`
|
||||
- Tipo: `imap`
|
||||
36
apps/docs/content/docs/fr/tools/imap.mdx
Normal file
36
apps/docs/content/docs/fr/tools/imap.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Email IMAP
|
||||
description: Déclenchez des workflows lorsque de nouveaux emails arrivent via
|
||||
IMAP (fonctionne avec n'importe quel fournisseur d'email)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="imap"
|
||||
color="#6366F1"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
Le déclencheur Email IMAP permet à vos workflows Sim de démarrer automatiquement dès qu'un nouvel email est reçu dans n'importe quelle boîte mail prenant en charge le protocole IMAP. Cela fonctionne avec Gmail, Outlook, Yahoo et la plupart des autres fournisseurs d'email.
|
||||
|
||||
Avec le déclencheur IMAP, vous pouvez :
|
||||
|
||||
- **Automatiser le traitement des emails** : démarrez des workflows en temps réel lorsque de nouveaux messages arrivent dans votre boîte de réception.
|
||||
- **Filtrer par expéditeur, objet ou dossier** : configurez votre déclencheur pour réagir uniquement aux emails correspondant à certaines conditions.
|
||||
- **Extraire et traiter les pièces jointes** : téléchargez et utilisez automatiquement les fichiers joints dans vos flux automatisés.
|
||||
- **Analyser et utiliser le contenu des emails** : accédez à l'objet, l'expéditeur, les destinataires, le corps complet et d'autres métadonnées dans les étapes suivantes du workflow.
|
||||
- **Intégrer avec n'importe quel fournisseur d'email** : fonctionne avec tout service offrant un accès IMAP standard, sans dépendance à un fournisseur.
|
||||
- **Déclencher sur non lu, marqué ou critères personnalisés** : configurez des filtres avancés pour les types d'emails qui démarrent vos workflows.
|
||||
|
||||
Avec Sim, l'intégration IMAP vous donne le pouvoir de transformer l'email en une source d'automatisation exploitable. Répondez aux demandes clients, traitez les notifications, lancez des pipelines de données et plus encore, directement depuis votre boîte de réception email, sans intervention manuelle.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Instructions d'utilisation
|
||||
|
||||
Connectez-vous à n'importe quel serveur email via le protocole IMAP pour déclencher des workflows lorsque de nouveaux emails sont reçus. Prend en charge Gmail, Outlook, Yahoo et tout autre fournisseur d'email compatible IMAP.
|
||||
|
||||
## Remarques
|
||||
|
||||
- Catégorie : `triggers`
|
||||
- Type : `imap`
|
||||
35
apps/docs/content/docs/ja/tools/imap.mdx
Normal file
35
apps/docs/content/docs/ja/tools/imap.mdx
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: IMAPメール
|
||||
description: IMAP経由で新しいメールが届いたときにワークフローをトリガー(すべてのメールプロバイダーで動作)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="imap"
|
||||
color="#6366F1"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
IMAPメールトリガーを使用すると、IMAPプロトコルをサポートする任意のメールボックスで新しいメールを受信したときに、Simワークフローを自動的に開始できます。Gmail、Outlook、Yahoo、その他ほとんどのメールプロバイダーで動作します。
|
||||
|
||||
IMAPトリガーでできること:
|
||||
|
||||
- **メール処理の自動化**:受信トレイに新しいメッセージが届いたときにリアルタイムでワークフローを開始します。
|
||||
- **送信者、件名、フォルダーでフィルタリング**:特定の条件に一致するメールにのみ反応するようにトリガーを設定します。
|
||||
- **添付ファイルの抽出と処理**:自動化フローでファイル添付を自動的にダウンロードして使用します。
|
||||
- **メールコンテンツの解析と使用**:件名、送信者、受信者、本文全体、その他のメタデータに、ワークフローの後続ステップでアクセスします。
|
||||
- **あらゆるメールプロバイダーとの統合**:ベンダーロックインなしで、標準のIMAPアクセスを提供する任意のサービスで動作します。
|
||||
- **未読、フラグ付き、カスタム条件でトリガー**:ワークフローを開始するメールの種類に対して高度なフィルターを設定します。
|
||||
|
||||
Simを使用すると、IMAP統合により、メールを実行可能な自動化ソースに変える力が得られます。顧客からの問い合わせへの対応、通知の処理、データパイプラインの開始など、手動操作なしで、メール受信トレイから直接実行できます。
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## 使用方法
|
||||
|
||||
IMAPプロトコル経由で任意のメールサーバーに接続し、新しいメールを受信したときにワークフローをトリガーします。Gmail、Outlook、Yahoo、その他のIMAP互換メールプロバイダーをサポートします。
|
||||
|
||||
## 注意事項
|
||||
|
||||
- カテゴリ: `triggers`
|
||||
- タイプ: `imap`
|
||||
35
apps/docs/content/docs/zh/tools/imap.mdx
Normal file
35
apps/docs/content/docs/zh/tools/imap.mdx
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: IMAP 邮件
|
||||
description: 当通过 IMAP 收到新邮件时触发工作流(适用于任何邮箱服务商)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="imap"
|
||||
color="#6366F1"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
IMAP 邮件触发器可以让你的 Sim 工作流在任何支持 IMAP 协议的邮箱收到新邮件时自动启动。适用于 Gmail、Outlook、Yahoo 及大多数其他邮箱服务商。
|
||||
|
||||
使用 IMAP 触发器,你可以:
|
||||
|
||||
- **自动化邮件处理**:当新邮件到达收件箱时,实时启动工作流。
|
||||
- **按发件人、主题或文件夹筛选**:配置触发器,仅对符合特定条件的邮件做出响应。
|
||||
- **提取并处理附件**:自动下载并在自动化流程中使用邮件附件。
|
||||
- **解析并利用邮件内容**:在后续工作流步骤中访问主题、发件人、收件人、正文及其他元数据。
|
||||
- **与任意邮箱服务集成**:支持所有提供标准 IMAP 访问的服务,无需受限于特定厂商。
|
||||
- **按未读、标记或自定义条件触发**:为启动工作流的邮件设置高级筛选条件。
|
||||
|
||||
借助 Sim,IMAP 集成让你能够将邮件变为可操作的自动化来源。无需人工干预,即可直接从邮箱收件箱响应客户咨询、处理通知、启动数据流程等。
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## 使用说明
|
||||
|
||||
通过 IMAP 协议连接任意邮件服务器,在收到新邮件时触发工作流。支持 Gmail、Outlook、Yahoo 及所有兼容 IMAP 的邮箱服务商。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分类:`triggers`
|
||||
- 类型:`imap`
|
||||
@@ -50184,3 +50184,16 @@ checksums:
|
||||
content/12: 3a322eee25c8bd5d81e7ae92f4239300
|
||||
content/13: a82eb7d47a82c3289a00ccf27a860685
|
||||
content/14: 26b9713de1a21d662c198154b673fd7d
|
||||
b92b25e42e07ea0c1acc84c25f897c03:
|
||||
meta/title: b578e5df9a37263d79d61eea1550b381
|
||||
meta/description: a3ac2f556f8a1d72eee5058799e45b4f
|
||||
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
|
||||
content/1: 03b3a9b927648526481589ec2205aed1
|
||||
content/2: ad3b7957de2e6e94935420692f41912b
|
||||
content/3: b5d66b7cc95f747232f3c39e71e58125
|
||||
content/4: a97fd9d5ca27813be7aa04fc9162ec5d
|
||||
content/5: 47006103fb87648dd28524557c946bd0
|
||||
content/6: 821e6394b0a953e2b0842b04ae8f3105
|
||||
content/7: 7b29d23aec8fda839f3934c5fc71c6d3
|
||||
content/8: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/9: 79ecd09a7bedc128285814d8b439ed40
|
||||
|
||||
@@ -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 {
|
||||
|
||||
42
apps/sim/app/agent/page.tsx
Normal file
42
apps/sim/app/agent/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Copilot } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
|
||||
/**
|
||||
* Superagent page - standalone AI agent with full credential access
|
||||
* Uses the exact same Copilot UI but with superagent mode forced
|
||||
*/
|
||||
export default function AgentPage() {
|
||||
const { setMode } = useCopilotStore()
|
||||
|
||||
// Set superagent mode on mount
|
||||
useEffect(() => {
|
||||
setMode('superagent')
|
||||
}, [setMode])
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className='flex h-screen flex-col bg-[var(--surface-1)]'>
|
||||
{/* Header */}
|
||||
<header className='flex h-14 flex-shrink-0 items-center justify-between border-b border-[var(--border)] px-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<h1 className='font-semibold text-lg text-[var(--text-primary)]'>Superagent</h1>
|
||||
<span className='rounded-full bg-[var(--accent)]/10 px-2 py-0.5 font-medium text-[var(--accent)] text-xs'>
|
||||
Full Access
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Copilot - exact same component in standalone mode */}
|
||||
<div className='flex-1 overflow-hidden p-4'>
|
||||
<div className='mx-auto h-full max-w-4xl'>
|
||||
<Copilot panelWidth={800} standalone />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { render } from '@react-email/components'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
|
||||
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
|
||||
import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
|
||||
@@ -156,6 +156,11 @@ describe('Chat OTP API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock()
|
||||
})
|
||||
|
||||
vi.doMock('zod', () => ({
|
||||
z: {
|
||||
object: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { renderOTPEmail } from '@/components/emails/render-email'
|
||||
import { renderOTPEmail } from '@/components/emails'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { getStorageMethod } from '@/lib/core/storage'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
@@ -249,17 +249,13 @@ describe('Chat API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
@@ -296,15 +292,13 @@ describe('Chat API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
|
||||
@@ -21,12 +21,13 @@ describe('Copilot API Keys API Route', () => {
|
||||
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: null,
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
SIM_AGENT_API_URL: undefined,
|
||||
COPILOT_API_KEY: 'test-api-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const ChatMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
userMessageId: z.string().optional(), // ID from frontend for the user message
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
workflowId: z.string().optional(),
|
||||
model: z
|
||||
.enum([
|
||||
'gpt-5-fast',
|
||||
@@ -63,7 +63,7 @@ const ChatMessageSchema = z.object({
|
||||
])
|
||||
.optional()
|
||||
.default('claude-4.5-opus'),
|
||||
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'),
|
||||
mode: z.enum(['ask', 'agent', 'plan', 'superagent']).optional().default('agent'),
|
||||
prefetch: z.boolean().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(true),
|
||||
@@ -339,7 +339,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
} | null = null
|
||||
|
||||
if (mode === 'agent') {
|
||||
if (mode === 'agent' || mode === 'superagent') {
|
||||
// Build base tools (executed locally, not deferred)
|
||||
// Include function_execute for code execution capability
|
||||
baseTools = [
|
||||
|
||||
@@ -25,7 +25,7 @@ const ExecuteToolSchema = z.object({
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
arguments: z.record(z.any()).optional().default({}),
|
||||
workflowId: z.string().optional(),
|
||||
workflowId: z.string().nullish(), // Accept undefined or null for superagent mode
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => {
|
||||
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: null,
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
SIM_AGENT_API_URL: undefined,
|
||||
COPILOT_API_KEY: 'test-api-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
90
apps/sim/app/api/cron/cleanup-stale-executions/route.ts
Normal file
90
apps/sim/app/api/cron/cleanup-stale-executions/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, lt, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
|
||||
const logger = createLogger('CleanupStaleExecutions')
|
||||
|
||||
const STALE_THRESHOLD_MINUTES = 30
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authError = verifyCronAuth(request, 'Stale execution cleanup')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
logger.info('Starting stale execution cleanup job')
|
||||
|
||||
const staleThreshold = new Date(Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000)
|
||||
|
||||
const staleExecutions = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowExecutionLogs.status, 'running'),
|
||||
lt(workflowExecutionLogs.startedAt, staleThreshold)
|
||||
)
|
||||
)
|
||||
.limit(100)
|
||||
|
||||
logger.info(`Found ${staleExecutions.length} stale executions to clean up`)
|
||||
|
||||
let cleaned = 0
|
||||
let failed = 0
|
||||
|
||||
for (const execution of staleExecutions) {
|
||||
try {
|
||||
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
|
||||
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
|
||||
|
||||
await db
|
||||
.update(workflowExecutionLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
endedAt: new Date(),
|
||||
totalDurationMs: staleDurationMs,
|
||||
executionData: sql`jsonb_set(
|
||||
COALESCE(execution_data, '{}'::jsonb),
|
||||
ARRAY['error'],
|
||||
to_jsonb(${`Execution terminated: worker timeout or crash after ${staleDurationMinutes} minutes`}::text)
|
||||
)`,
|
||||
})
|
||||
.where(eq(workflowExecutionLogs.id, execution.id))
|
||||
|
||||
logger.info(`Cleaned up stale execution ${execution.executionId}`, {
|
||||
workflowId: execution.workflowId,
|
||||
staleDurationMinutes,
|
||||
})
|
||||
|
||||
cleaned++
|
||||
} catch (error) {
|
||||
logger.error(`Failed to clean up execution ${execution.executionId}:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Stale execution cleanup completed. Cleaned: ${cleaned}, Failed: ${failed}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
found: staleExecutions.length,
|
||||
cleaned,
|
||||
failed,
|
||||
thresholdMinutes: STALE_THRESHOLD_MINUTES,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error in stale execution cleanup job:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
176
apps/sim/app/api/emails/preview/route.ts
Normal file
176
apps/sim/app/api/emails/preview/route.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
renderBatchInvitationEmail,
|
||||
renderCareersConfirmationEmail,
|
||||
renderCareersSubmissionEmail,
|
||||
renderCreditPurchaseEmail,
|
||||
renderEnterpriseSubscriptionEmail,
|
||||
renderFreeTierUpgradeEmail,
|
||||
renderHelpConfirmationEmail,
|
||||
renderInvitationEmail,
|
||||
renderOTPEmail,
|
||||
renderPasswordResetEmail,
|
||||
renderPaymentFailedEmail,
|
||||
renderPlanWelcomeEmail,
|
||||
renderUsageThresholdEmail,
|
||||
renderWelcomeEmail,
|
||||
renderWorkspaceInvitationEmail,
|
||||
} from '@/components/emails'
|
||||
|
||||
const emailTemplates = {
|
||||
// Auth emails
|
||||
otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'),
|
||||
'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
|
||||
welcome: () => renderWelcomeEmail('John'),
|
||||
|
||||
// Invitation emails
|
||||
invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
|
||||
'batch-invitation': () =>
|
||||
renderBatchInvitationEmail(
|
||||
'Jane Doe',
|
||||
'Acme Corp',
|
||||
'admin',
|
||||
[
|
||||
{ workspaceId: 'ws_123', workspaceName: 'Engineering', permission: 'write' },
|
||||
{ workspaceId: 'ws_456', workspaceName: 'Design', permission: 'read' },
|
||||
],
|
||||
'https://sim.ai/invite/abc123'
|
||||
),
|
||||
'workspace-invitation': () =>
|
||||
renderWorkspaceInvitationEmail(
|
||||
'John Smith',
|
||||
'Engineering Team',
|
||||
'https://sim.ai/workspace/invite/abc123'
|
||||
),
|
||||
|
||||
// Support emails
|
||||
'help-confirmation': () => renderHelpConfirmationEmail('feature_request', 2),
|
||||
|
||||
// Billing emails
|
||||
'usage-threshold': () =>
|
||||
renderUsageThresholdEmail({
|
||||
userName: 'John',
|
||||
planName: 'Pro',
|
||||
percentUsed: 75,
|
||||
currentUsage: 15,
|
||||
limit: 20,
|
||||
ctaLink: 'https://sim.ai/settings/billing',
|
||||
}),
|
||||
'enterprise-subscription': () => renderEnterpriseSubscriptionEmail('John'),
|
||||
'free-tier-upgrade': () =>
|
||||
renderFreeTierUpgradeEmail({
|
||||
userName: 'John',
|
||||
percentUsed: 90,
|
||||
currentUsage: 9,
|
||||
limit: 10,
|
||||
upgradeLink: 'https://sim.ai/settings/billing',
|
||||
}),
|
||||
'plan-welcome-pro': () =>
|
||||
renderPlanWelcomeEmail({
|
||||
planName: 'Pro',
|
||||
userName: 'John',
|
||||
loginLink: 'https://sim.ai/login',
|
||||
}),
|
||||
'plan-welcome-team': () =>
|
||||
renderPlanWelcomeEmail({
|
||||
planName: 'Team',
|
||||
userName: 'John',
|
||||
loginLink: 'https://sim.ai/login',
|
||||
}),
|
||||
'credit-purchase': () =>
|
||||
renderCreditPurchaseEmail({
|
||||
userName: 'John',
|
||||
amount: 50,
|
||||
newBalance: 75,
|
||||
}),
|
||||
'payment-failed': () =>
|
||||
renderPaymentFailedEmail({
|
||||
userName: 'John',
|
||||
amountDue: 20,
|
||||
lastFourDigits: '4242',
|
||||
billingPortalUrl: 'https://sim.ai/settings/billing',
|
||||
failureReason: 'Card declined',
|
||||
}),
|
||||
|
||||
// Careers emails
|
||||
'careers-confirmation': () => renderCareersConfirmationEmail('John Doe', 'Senior Engineer'),
|
||||
'careers-submission': () =>
|
||||
renderCareersSubmissionEmail({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '+1 (555) 123-4567',
|
||||
position: 'Senior Engineer',
|
||||
linkedin: 'https://linkedin.com/in/johndoe',
|
||||
portfolio: 'https://johndoe.dev',
|
||||
experience: '5-10',
|
||||
location: 'San Francisco, CA',
|
||||
message:
|
||||
'I have 10 years of experience building scalable distributed systems. Most recently, I led a team at a Series B startup where we scaled from 100K to 10M users.',
|
||||
}),
|
||||
} as const
|
||||
|
||||
type EmailTemplate = keyof typeof emailTemplates
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const template = searchParams.get('template') as EmailTemplate | null
|
||||
|
||||
if (!template) {
|
||||
const categories = {
|
||||
Auth: ['otp', 'reset-password', 'welcome'],
|
||||
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'],
|
||||
Support: ['help-confirmation'],
|
||||
Billing: [
|
||||
'usage-threshold',
|
||||
'enterprise-subscription',
|
||||
'free-tier-upgrade',
|
||||
'plan-welcome-pro',
|
||||
'plan-welcome-team',
|
||||
'credit-purchase',
|
||||
'payment-failed',
|
||||
],
|
||||
Careers: ['careers-confirmation', 'careers-submission'],
|
||||
}
|
||||
|
||||
const categoryHtml = Object.entries(categories)
|
||||
.map(
|
||||
([category, templates]) => `
|
||||
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
</ul>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
|
||||
return new NextResponse(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Email Previews</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
|
||||
h1 { color: #333; margin-bottom: 32px; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Email Templates</h1>
|
||||
${categoryHtml}
|
||||
</body>
|
||||
</html>`,
|
||||
{ headers: { 'Content-Type': 'text/html' } }
|
||||
)
|
||||
}
|
||||
|
||||
if (!(template in emailTemplates)) {
|
||||
return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const html = await emailTemplates[template]()
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
}
|
||||
@@ -118,7 +118,6 @@ ${message}
|
||||
// Send confirmation email to the user
|
||||
try {
|
||||
const confirmationHtml = await renderHelpConfirmationEmail(
|
||||
email,
|
||||
type as 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
images.length
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getEmailSubject,
|
||||
renderBatchInvitationEmail,
|
||||
renderInvitationEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
} from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
validateBulkInvitations,
|
||||
@@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${getBaseUrl()}/invite/${orgInvitation.id}`,
|
||||
email
|
||||
`${getBaseUrl()}/invite/${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { invitation, member, organization, user, userStats } from '@sim/db/schem
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
@@ -260,8 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${getBaseUrl()}/invite/organization?id=${invitationId}`,
|
||||
normalizedEmail
|
||||
`${getBaseUrl()}/invite/organization?id=${invitationId}`
|
||||
)
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
|
||||
101
apps/sim/app/api/tools/imap/mailboxes/route.ts
Normal file
101
apps/sim/app/api/tools/imap/mailboxes/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('ImapMailboxesAPI')
|
||||
|
||||
interface ImapMailboxRequest {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
rejectUnauthorized: boolean
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as ImapMailboxRequest
|
||||
const { host, port, secure, rejectUnauthorized, username, password } = body
|
||||
|
||||
if (!host || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Missing required fields: host, username, password' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port: port || 993,
|
||||
secure: secure ?? true,
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: rejectUnauthorized ?? true,
|
||||
},
|
||||
logger: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
|
||||
const listResult = await client.list()
|
||||
const mailboxes = listResult.map((mailbox) => ({
|
||||
path: mailbox.path,
|
||||
name: mailbox.name,
|
||||
delimiter: mailbox.delimiter,
|
||||
}))
|
||||
|
||||
await client.logout()
|
||||
|
||||
mailboxes.sort((a, b) => {
|
||||
if (a.path === 'INBOX') return -1
|
||||
if (b.path === 'INBOX') return 1
|
||||
return a.path.localeCompare(b.path)
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mailboxes,
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.logout()
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Error fetching IMAP mailboxes:', errorMessage)
|
||||
|
||||
let userMessage = 'Failed to connect to IMAP server'
|
||||
if (
|
||||
errorMessage.includes('AUTHENTICATIONFAILED') ||
|
||||
errorMessage.includes('Invalid credentials')
|
||||
) {
|
||||
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
|
||||
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
|
||||
userMessage = 'Could not find IMAP server. Please check the hostname.'
|
||||
} else if (errorMessage.includes('ECONNREFUSED')) {
|
||||
userMessage = 'Connection refused. Please check the port and SSL settings.'
|
||||
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
|
||||
userMessage =
|
||||
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage = 'Connection timed out. Please check your network and server settings.'
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@
|
||||
* DELETE /api/v1/admin/workflows/:id - Delete workflow
|
||||
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
|
||||
* POST /api/v1/admin/workflows/import - Import single workflow
|
||||
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
|
||||
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
|
||||
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
|
||||
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
|
||||
*
|
||||
* Organizations:
|
||||
* GET /api/v1/admin/organizations - List all organizations
|
||||
@@ -65,6 +69,8 @@ export {
|
||||
unauthorizedResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
export type {
|
||||
AdminDeploymentVersion,
|
||||
AdminDeployResult,
|
||||
AdminErrorResponse,
|
||||
AdminFolder,
|
||||
AdminListResponse,
|
||||
@@ -76,6 +82,7 @@ export type {
|
||||
AdminSeatAnalytics,
|
||||
AdminSingleResponse,
|
||||
AdminSubscription,
|
||||
AdminUndeployResult,
|
||||
AdminUser,
|
||||
AdminUserBilling,
|
||||
AdminUserBillingWithSubscription,
|
||||
|
||||
@@ -599,3 +599,23 @@ export interface AdminSeatAnalytics {
|
||||
lastActive: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AdminDeploymentVersion {
|
||||
id: string
|
||||
version: number
|
||||
name: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
createdBy: string | null
|
||||
deployedByName: string | null
|
||||
}
|
||||
|
||||
export interface AdminDeployResult {
|
||||
isDeployed: boolean
|
||||
version: number
|
||||
deployedAt: string
|
||||
}
|
||||
|
||||
export interface AdminUndeployResult {
|
||||
isDeployed: boolean
|
||||
}
|
||||
|
||||
111
apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts
Normal file
111
apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { db, workflow } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
deployWorkflow,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
undeployWorkflow,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowDeployAPI')
|
||||
|
||||
const ADMIN_ACTOR_ID = 'admin-api'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: workflowId } = await context.params
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalizedData) {
|
||||
return badRequestResponse('Workflow has no saved state')
|
||||
}
|
||||
|
||||
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
|
||||
if (!scheduleValidation.isValid) {
|
||||
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
|
||||
}
|
||||
|
||||
const deployResult = await deployWorkflow({
|
||||
workflowId,
|
||||
deployedBy: ADMIN_ACTOR_ID,
|
||||
workflowName: workflowRecord.name,
|
||||
})
|
||||
|
||||
if (!deployResult.success) {
|
||||
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
|
||||
}
|
||||
|
||||
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
|
||||
if (!scheduleResult.success) {
|
||||
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
|
||||
|
||||
const response: AdminDeployResult = {
|
||||
isDeployed: true,
|
||||
version: deployResult.version!,
|
||||
deployedAt: deployResult.deployedAt!.toISOString(),
|
||||
}
|
||||
|
||||
return singleResponse(response)
|
||||
} catch (error) {
|
||||
logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error })
|
||||
return internalErrorResponse('Failed to deploy workflow')
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: workflowId } = await context.params
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const result = await undeployWorkflow({ workflowId })
|
||||
if (!result.success) {
|
||||
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
|
||||
|
||||
const response: AdminUndeployResult = {
|
||||
isDeployed: false,
|
||||
}
|
||||
|
||||
return singleResponse(response)
|
||||
} catch (error) {
|
||||
logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error })
|
||||
return internalErrorResponse('Failed to undeploy workflow')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { db, workflow } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminWorkflowActivateVersionAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
versionId: string
|
||||
}
|
||||
|
||||
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: workflowId, versionId } = await context.params
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const versionNum = Number(versionId)
|
||||
if (!Number.isFinite(versionNum) || versionNum < 1) {
|
||||
return badRequestResponse('Invalid version number')
|
||||
}
|
||||
|
||||
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
|
||||
if (!result.success) {
|
||||
if (result.error === 'Deployment version not found') {
|
||||
return notFoundResponse('Deployment version')
|
||||
}
|
||||
return internalErrorResponse(result.error || 'Failed to activate version')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
version: versionNum,
|
||||
deployedAt: result.deployedAt!.toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
|
||||
return internalErrorResponse('Failed to activate deployment version')
|
||||
}
|
||||
})
|
||||
52
apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts
Normal file
52
apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { db, workflow } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminDeploymentVersion } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowVersionsAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: workflowId } = await context.params
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const { versions } = await listWorkflowVersions(workflowId)
|
||||
|
||||
const response: AdminDeploymentVersion[] = versions.map((v) => ({
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
name: v.name,
|
||||
isActive: v.isActive,
|
||||
createdAt: v.createdAt.toISOString(),
|
||||
createdBy: v.createdBy,
|
||||
deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
|
||||
}))
|
||||
|
||||
logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`)
|
||||
|
||||
return singleResponse({ versions: response })
|
||||
} catch (error) {
|
||||
logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error })
|
||||
return internalErrorResponse('Failed to list deployment versions')
|
||||
}
|
||||
})
|
||||
68
apps/sim/app/api/webhooks/poll/imap/route.ts
Normal file
68
apps/sim/app/api/webhooks/poll/imap/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
|
||||
import { pollImapWebhooks } from '@/lib/webhooks/imap-polling-service'
|
||||
|
||||
const logger = createLogger('ImapPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
const LOCK_KEY = 'imap-polling-lock'
|
||||
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = nanoid()
|
||||
logger.info(`IMAP webhook polling triggered (${requestId})`)
|
||||
|
||||
let lockValue: string | undefined
|
||||
|
||||
try {
|
||||
const authError = verifyCronAuth(request, 'IMAP webhook polling')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
lockValue = requestId // unique value to identify the holder
|
||||
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
|
||||
|
||||
if (!locked) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Polling already in progress – skipped',
|
||||
requestId,
|
||||
status: 'skip',
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
}
|
||||
|
||||
const results = await pollImapWebhooks()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'IMAP polling completed',
|
||||
requestId,
|
||||
status: 'completed',
|
||||
...results,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error during IMAP polling (${requestId}):`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'IMAP polling failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
requestId,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
if (lockValue) {
|
||||
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,6 +581,56 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
// --- End RSS specific logic ---
|
||||
|
||||
if (savedWebhook && provider === 'grain') {
|
||||
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
|
||||
try {
|
||||
const grainHookId = await createGrainWebhookSubscription(
|
||||
request,
|
||||
{
|
||||
id: savedWebhook.id,
|
||||
path: savedWebhook.path,
|
||||
providerConfig: savedWebhook.providerConfig,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
if (grainHookId) {
|
||||
// Update the webhook record with the external Grain hook ID
|
||||
const updatedConfig = {
|
||||
...(savedWebhook.providerConfig as Record<string, any>),
|
||||
externalId: grainHookId,
|
||||
}
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: updatedConfig,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, savedWebhook.id))
|
||||
|
||||
savedWebhook.providerConfig = updatedConfig
|
||||
logger.info(`[${requestId}] Successfully created Grain webhook`, {
|
||||
grainHookId,
|
||||
webhookId: savedWebhook.id,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[${requestId}] Error creating Grain webhook subscription, rolling back webhook`,
|
||||
err
|
||||
)
|
||||
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to create webhook in Grain',
|
||||
details: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
// --- End Grain specific logic ---
|
||||
|
||||
const status = targetWebhookId ? 200 : 201
|
||||
return NextResponse.json({ webhook: savedWebhook }, { status })
|
||||
} catch (error: any) {
|
||||
@@ -947,3 +997,103 @@ async function createWebflowWebhookSubscription(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create the webhook subscription in Grain
|
||||
async function createGrainWebhookSubscription(
|
||||
request: NextRequest,
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const { path, providerConfig } = webhookData
|
||||
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
|
||||
providerConfig || {}
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
|
||||
webhookId: webhookData.id,
|
||||
})
|
||||
throw new Error(
|
||||
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
|
||||
)
|
||||
}
|
||||
|
||||
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
|
||||
|
||||
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
|
||||
|
||||
const requestBody: Record<string, any> = {
|
||||
hook_url: notificationUrl,
|
||||
}
|
||||
|
||||
// Build include object based on configuration
|
||||
const include: Record<string, boolean> = {}
|
||||
if (includeHighlights) {
|
||||
include.highlights = true
|
||||
}
|
||||
if (includeParticipants) {
|
||||
include.participants = true
|
||||
}
|
||||
if (includeAiSummary) {
|
||||
include.ai_summary = true
|
||||
}
|
||||
if (Object.keys(include).length > 0) {
|
||||
requestBody.include = include
|
||||
}
|
||||
|
||||
const grainResponse = await fetch(grainApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Public-Api-Version': '2025-10-31',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
const responseBody = await grainResponse.json()
|
||||
|
||||
if (!grainResponse.ok || responseBody.error) {
|
||||
const errorMessage =
|
||||
responseBody.error?.message ||
|
||||
responseBody.error ||
|
||||
responseBody.message ||
|
||||
'Unknown Grain API error'
|
||||
logger.error(
|
||||
`[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`,
|
||||
{ message: errorMessage, response: responseBody }
|
||||
)
|
||||
|
||||
let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
|
||||
if (grainResponse.status === 401) {
|
||||
userFriendlyMessage =
|
||||
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
|
||||
} else if (grainResponse.status === 403) {
|
||||
userFriendlyMessage =
|
||||
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
|
||||
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
|
||||
userFriendlyMessage = `Grain error: ${errorMessage}`
|
||||
}
|
||||
|
||||
throw new Error(userFriendlyMessage)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
|
||||
{
|
||||
grainWebhookId: responseBody.id,
|
||||
}
|
||||
)
|
||||
|
||||
return responseBody.id
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
|
||||
{
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,12 +4,12 @@ import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
createSchedulesForDeploy,
|
||||
deleteSchedulesForWorkflow,
|
||||
validateWorkflowSchedules,
|
||||
} from '@/lib/workflows/schedules'
|
||||
deployWorkflow,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
undeployWorkflow,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -207,21 +207,11 @@ export async function DELETE(
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await deleteSchedulesForWorkflow(id, tx)
|
||||
const result = await undeployWorkflow({ workflowId: id })
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: false })
|
||||
.where(eq(workflowDeploymentVersion.workflowId, id))
|
||||
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ isDeployed: false, deployedAt: null })
|
||||
.where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Remove all MCP tools that reference this workflow
|
||||
await removeMcpToolsForWorkflow(id, requestId)
|
||||
|
||||
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -27,68 +26,24 @@ export async function POST(
|
||||
|
||||
const versionNum = Number(version)
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
return createErrorResponse('Invalid version', 400)
|
||||
return createErrorResponse('Invalid version number', 400)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
|
||||
}
|
||||
|
||||
// Get the state of the version being activated for MCP tool sync
|
||||
const [versionData] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: false })
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
|
||||
const updated = await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: true })
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.returning({ id: workflowDeploymentVersion.id })
|
||||
|
||||
if (updated.length === 0) {
|
||||
throw new Error('Deployment version not found')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
isDeployed: true,
|
||||
deployedAt: now,
|
||||
}
|
||||
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Sync MCP tools with the activated version's parameter schema
|
||||
if (versionData?.state) {
|
||||
if (result.state) {
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: id,
|
||||
requestId,
|
||||
state: versionData.state,
|
||||
state: result.state,
|
||||
context: 'activate',
|
||||
})
|
||||
}
|
||||
|
||||
return createSuccessResponse({ success: true, deployedAt: now })
|
||||
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
||||
return createErrorResponse(error.message || 'Failed to activate deployment', 500)
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
const versions = await db
|
||||
const rawVersions = await db
|
||||
.select({
|
||||
id: workflowDeploymentVersion.id,
|
||||
version: workflowDeploymentVersion.version,
|
||||
@@ -36,6 +36,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
.where(eq(workflowDeploymentVersion.workflowId, id))
|
||||
.orderBy(desc(workflowDeploymentVersion.version))
|
||||
|
||||
const versions = rawVersions.map((v) => ({
|
||||
...v,
|
||||
deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
|
||||
}))
|
||||
|
||||
return createSuccessResponse({ versions })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error)
|
||||
|
||||
@@ -12,7 +12,6 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { processInputFileFields } from '@/lib/execution/files'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
@@ -24,16 +23,17 @@ import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
|
||||
import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
const logger = createLogger('WorkflowExecuteAPI')
|
||||
|
||||
const ExecuteWorkflowSchema = z.object({
|
||||
selectedOutputs: z.array(z.string()).optional().default([]),
|
||||
triggerType: z.enum(ALL_TRIGGER_TYPES).optional(),
|
||||
triggerType: z.enum(CORE_TRIGGER_TYPES).optional(),
|
||||
stream: z.boolean().optional(),
|
||||
useDraftState: z.boolean().optional(),
|
||||
input: z.any().optional(),
|
||||
@@ -541,11 +541,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
iterationContext?: {
|
||||
iterationCurrent: number
|
||||
iterationTotal: number
|
||||
iterationType: SubflowType
|
||||
}
|
||||
iterationContext?: IterationContext
|
||||
) => {
|
||||
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
|
||||
sendEvent({
|
||||
@@ -571,11 +567,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
callbackData: any,
|
||||
iterationContext?: {
|
||||
iterationCurrent: number
|
||||
iterationTotal: number
|
||||
iterationType: SubflowType
|
||||
}
|
||||
iterationContext?: IterationContext
|
||||
) => {
|
||||
const hasError = callbackData.output?.error
|
||||
|
||||
@@ -713,14 +705,25 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||
executionId,
|
||||
})
|
||||
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||
} else {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId,
|
||||
executionId,
|
||||
pausePoints: result.pausePoints || [],
|
||||
snapshotSeed: result.snapshotSeed,
|
||||
executorUserId: result.metadata?.userId,
|
||||
})
|
||||
try {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId,
|
||||
executionId,
|
||||
pausePoints: result.pausePoints || [],
|
||||
snapshotSeed: result.snapshotSeed,
|
||||
executorUserId: result.metadata?.userId,
|
||||
})
|
||||
} catch (pauseError) {
|
||||
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||
executionId,
|
||||
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||
})
|
||||
await loggingSession.markAsFailed(
|
||||
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await PauseResumeManager.processQueuedResumes(executionId)
|
||||
|
||||
@@ -6,14 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
|
||||
|
||||
const logger = createLogger('WorkspaceNotificationAPI')
|
||||
|
||||
const levelFilterSchema = z.array(z.enum(['info', 'error']))
|
||||
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
|
||||
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
|
||||
|
||||
const alertRuleSchema = z.enum([
|
||||
'consecutive_failures',
|
||||
|
||||
@@ -7,15 +7,15 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
|
||||
|
||||
const logger = createLogger('WorkspaceNotificationsAPI')
|
||||
|
||||
const notificationTypeSchema = z.enum(['webhook', 'email', 'slack'])
|
||||
const levelFilterSchema = z.array(z.enum(['info', 'error']))
|
||||
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
|
||||
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
|
||||
|
||||
const alertRuleSchema = z.enum([
|
||||
'consecutive_failures',
|
||||
@@ -81,7 +81,7 @@ const createNotificationSchema = z
|
||||
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]),
|
||||
allWorkflows: z.boolean().default(false),
|
||||
levelFilter: levelFilterSchema.default(['info', 'error']),
|
||||
triggerFilter: triggerFilterSchema.default([...ALL_TRIGGER_TYPES]),
|
||||
triggerFilter: triggerFilterSchema.default([...CORE_TRIGGER_TYPES]),
|
||||
includeFinalOutput: z.boolean().default(false),
|
||||
includeTraceSpans: z.boolean().default(false),
|
||||
includeRateLimits: z.boolean().default(false),
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -87,14 +87,10 @@ describe('Workspace Invitations API Route', () => {
|
||||
WorkspaceInvitationEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
RESEND_API_KEY: 'test-resend-key',
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
|
||||
EMAIL_DOMAIN: 'test.sim.ai',
|
||||
},
|
||||
}))
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock()
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/core/utils/urls', () => ({
|
||||
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,35 +8,156 @@ import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||
|
||||
const logger = createLogger('InviteById')
|
||||
|
||||
function getErrorMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-token':
|
||||
return 'The invitation link is invalid or missing a required parameter.'
|
||||
case 'invalid-token':
|
||||
return 'The invitation link is invalid or has already been used.'
|
||||
case 'expired':
|
||||
return 'This invitation has expired. Please ask for a new invitation.'
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return 'This invitation was sent to a different email address. Please log in with the correct account.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'already-in-organization':
|
||||
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||
case 'server-error':
|
||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||
default:
|
||||
return 'An unknown error occurred while processing your invitation.'
|
||||
/** Error codes that can occur during invitation processing */
|
||||
type InviteErrorCode =
|
||||
| 'missing-token'
|
||||
| 'invalid-token'
|
||||
| 'expired'
|
||||
| 'already-processed'
|
||||
| 'email-mismatch'
|
||||
| 'workspace-not-found'
|
||||
| 'user-not-found'
|
||||
| 'already-member'
|
||||
| 'already-in-organization'
|
||||
| 'invalid-invitation'
|
||||
| 'missing-invitation-id'
|
||||
| 'server-error'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'network-error'
|
||||
| 'unknown'
|
||||
|
||||
interface InviteError {
|
||||
code: InviteErrorCode
|
||||
message: string
|
||||
requiresAuth?: boolean
|
||||
canRetry?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps error codes to user-friendly error objects with contextual information
|
||||
*/
|
||||
function getInviteError(reason: string): InviteError {
|
||||
const errorMap: Record<string, InviteError> = {
|
||||
'missing-token': {
|
||||
code: 'missing-token',
|
||||
message: 'The invitation link is invalid or missing a required parameter.',
|
||||
},
|
||||
'invalid-token': {
|
||||
code: 'invalid-token',
|
||||
message: 'The invitation link is invalid or has already been used.',
|
||||
},
|
||||
expired: {
|
||||
code: 'expired',
|
||||
message: 'This invitation has expired. Please ask for a new invitation.',
|
||||
},
|
||||
'already-processed': {
|
||||
code: 'already-processed',
|
||||
message: 'This invitation has already been accepted or declined.',
|
||||
},
|
||||
'email-mismatch': {
|
||||
code: 'email-mismatch',
|
||||
message:
|
||||
'This invitation was sent to a different email address. Please sign in with the correct account.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
'workspace-not-found': {
|
||||
code: 'workspace-not-found',
|
||||
message: 'The workspace associated with this invitation could not be found.',
|
||||
},
|
||||
'user-not-found': {
|
||||
code: 'user-not-found',
|
||||
message: 'Your user account could not be found. Please try signing out and signing back in.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
'already-member': {
|
||||
code: 'already-member',
|
||||
message: 'You are already a member of this organization or workspace.',
|
||||
},
|
||||
'already-in-organization': {
|
||||
code: 'already-in-organization',
|
||||
message:
|
||||
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
|
||||
},
|
||||
'invalid-invitation': {
|
||||
code: 'invalid-invitation',
|
||||
message: 'This invitation is invalid or no longer exists.',
|
||||
},
|
||||
'missing-invitation-id': {
|
||||
code: 'missing-invitation-id',
|
||||
message:
|
||||
'The invitation link is missing required information. Please use the original invitation link.',
|
||||
},
|
||||
'server-error': {
|
||||
code: 'server-error',
|
||||
message:
|
||||
'An unexpected error occurred while processing your invitation. Please try again later.',
|
||||
canRetry: true,
|
||||
},
|
||||
unauthorized: {
|
||||
code: 'unauthorized',
|
||||
message: 'You need to sign in to accept this invitation.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
forbidden: {
|
||||
code: 'forbidden',
|
||||
message:
|
||||
'You do not have permission to accept this invitation. Please check you are signed in with the correct account.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
'network-error': {
|
||||
code: 'network-error',
|
||||
message:
|
||||
'Unable to connect to the server. Please check your internet connection and try again.',
|
||||
canRetry: true,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
errorMap[reason] || {
|
||||
code: 'unknown',
|
||||
message:
|
||||
'An unexpected error occurred while processing your invitation. Please try again or contact support.',
|
||||
canRetry: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses API error responses and extracts a standardized error code
|
||||
*/
|
||||
function parseApiError(error: unknown, statusCode?: number): InviteErrorCode {
|
||||
// Handle network/fetch errors
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
return 'network-error'
|
||||
}
|
||||
|
||||
// Handle error message patterns first (more specific matching)
|
||||
const errorMessage =
|
||||
typeof error === 'string' ? error.toLowerCase() : (error as Error)?.message?.toLowerCase() || ''
|
||||
|
||||
// Check specific patterns before falling back to status codes
|
||||
// Order matters: more specific patterns must come first
|
||||
if (errorMessage.includes('already a member of an organization')) return 'already-in-organization'
|
||||
if (errorMessage.includes('already a member')) return 'already-member'
|
||||
if (errorMessage.includes('email mismatch') || errorMessage.includes('different email'))
|
||||
return 'email-mismatch'
|
||||
if (errorMessage.includes('already processed')) return 'already-processed'
|
||||
if (errorMessage.includes('unauthorized')) return 'unauthorized'
|
||||
if (errorMessage.includes('forbidden') || errorMessage.includes('permission')) return 'forbidden'
|
||||
if (errorMessage.includes('not found') || errorMessage.includes('expired'))
|
||||
return 'invalid-invitation'
|
||||
|
||||
// Handle HTTP status codes as fallback
|
||||
if (statusCode) {
|
||||
if (statusCode === 401) return 'unauthorized'
|
||||
if (statusCode === 403) return 'forbidden'
|
||||
if (statusCode === 404) return 'invalid-invitation'
|
||||
if (statusCode === 409) return 'already-in-organization'
|
||||
if (statusCode >= 500) return 'server-error'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export default function Invite() {
|
||||
@@ -47,7 +168,7 @@ export default function Invite() {
|
||||
const { data: session, isPending } = useSession()
|
||||
const [invitationDetails, setInvitationDetails] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<InviteError | null>(null)
|
||||
const [isAccepting, setIsAccepting] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [isNewUser, setIsNewUser] = useState(false)
|
||||
@@ -59,7 +180,7 @@ export default function Invite() {
|
||||
const errorReason = searchParams.get('error')
|
||||
|
||||
if (errorReason) {
|
||||
setError(getErrorMessage(errorReason))
|
||||
setError(getInviteError(errorReason))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -99,11 +220,37 @@ export default function Invite() {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle workspace invitation errors with specific status codes
|
||||
if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) {
|
||||
const errorCode = parseApiError(null, workspaceInviteResponse.status)
|
||||
const errorData = await workspaceInviteResponse.json().catch(() => ({}))
|
||||
logger.error('Workspace invitation fetch failed:', {
|
||||
status: workspaceInviteResponse.status,
|
||||
error: errorData,
|
||||
})
|
||||
|
||||
// Refine error code based on response body if available
|
||||
if (errorData.error) {
|
||||
const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status)
|
||||
setError(getInviteError(refinedCode))
|
||||
} else {
|
||||
setError(getInviteError(errorCode))
|
||||
}
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await client.organization.getInvitation({
|
||||
const { data, error: orgError } = await client.organization.getInvitation({
|
||||
query: { id: inviteId },
|
||||
})
|
||||
|
||||
if (orgError) {
|
||||
logger.error('Organization invitation fetch error:', orgError)
|
||||
const errorCode = parseApiError(orgError.message || orgError)
|
||||
throw { code: errorCode, original: orgError }
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setInvitationType('organization')
|
||||
|
||||
@@ -115,7 +262,7 @@ export default function Invite() {
|
||||
if (activeOrgResponse?.data) {
|
||||
// User is already in an organization
|
||||
setCurrentOrgName(activeOrgResponse.data.name)
|
||||
setError('already-in-organization')
|
||||
setError(getInviteError('already-in-organization'))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -139,14 +286,19 @@ export default function Invite() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invitation not found or has expired')
|
||||
throw { code: 'invalid-invitation' }
|
||||
}
|
||||
} catch (_err) {
|
||||
throw new Error('Invitation not found or has expired')
|
||||
} catch (orgErr: any) {
|
||||
// If this is our structured error, use it directly
|
||||
if (orgErr.code) {
|
||||
throw orgErr
|
||||
}
|
||||
throw { code: parseApiError(orgErr) }
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Error fetching invitation:', err)
|
||||
setError(err.message || 'Failed to load invitation details')
|
||||
const errorCode = err.code || parseApiError(err)
|
||||
setError(getInviteError(errorCode))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -168,7 +320,9 @@ export default function Invite() {
|
||||
const orgId = invitationDetails?.data?.organizationId
|
||||
|
||||
if (!orgId) {
|
||||
throw new Error('Organization ID not found')
|
||||
setError(getInviteError('invalid-invitation'))
|
||||
setIsAccepting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Use our custom API endpoint that handles Pro usage snapshot
|
||||
@@ -182,8 +336,15 @@ export default function Invite() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ error: 'Failed to accept invitation' }))
|
||||
throw new Error(data.error || 'Failed to accept invitation')
|
||||
const data = await response.json().catch(() => ({}))
|
||||
const errorCode = parseApiError(data.error || '', response.status)
|
||||
logger.error('Failed to accept organization invitation:', {
|
||||
status: response.status,
|
||||
error: data,
|
||||
})
|
||||
setError(getInviteError(errorCode))
|
||||
setIsAccepting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the organization as active
|
||||
@@ -202,13 +363,8 @@ export default function Invite() {
|
||||
// Reset accepted state on error
|
||||
setAccepted(false)
|
||||
|
||||
// Check if it's a 409 conflict (already in an organization)
|
||||
if (err.status === 409 || err.message?.includes('already a member of an organization')) {
|
||||
setError('already-in-organization')
|
||||
} else {
|
||||
setError(err.message || 'Failed to accept invitation')
|
||||
}
|
||||
|
||||
const errorCode = parseApiError(err)
|
||||
setError(getInviteError(errorCode))
|
||||
setIsAccepting(false)
|
||||
}
|
||||
}
|
||||
@@ -279,12 +435,10 @@ export default function Invite() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorReason = searchParams.get('error')
|
||||
const isExpiredError = errorReason === 'expired'
|
||||
const isAlreadyInOrg = error === 'already-in-organization'
|
||||
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
||||
|
||||
// Special handling for already in organization
|
||||
if (isAlreadyInOrg) {
|
||||
if (error.code === 'already-in-organization') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
@@ -293,7 +447,7 @@ export default function Invite() {
|
||||
description={
|
||||
currentOrgName
|
||||
? `You are currently a member of "${currentOrgName}". You must leave your current organization before accepting a new invitation.`
|
||||
: 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
||||
: error.message
|
||||
}
|
||||
icon='users'
|
||||
actions={[
|
||||
@@ -313,24 +467,96 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Use getErrorMessage for consistent error messages
|
||||
const errorMessage = error.startsWith('You are already') ? error : getErrorMessage(error)
|
||||
// Handle email mismatch - user needs to sign in with a different account
|
||||
if (error.code === 'email-mismatch') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='warning'
|
||||
title='Wrong Account'
|
||||
description={error.message}
|
||||
icon='userPlus'
|
||||
actions={[
|
||||
{
|
||||
label: 'Sign in with a different account',
|
||||
onClick: async () => {
|
||||
await client.signOut()
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)
|
||||
},
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle auth-related errors - prompt user to sign in
|
||||
if (error.requiresAuth) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='warning'
|
||||
title='Authentication Required'
|
||||
description={error.message}
|
||||
icon='userPlus'
|
||||
actions={[
|
||||
{
|
||||
label: 'Sign in to continue',
|
||||
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle retryable errors
|
||||
const actions: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'outline' | 'ghost'
|
||||
}> = []
|
||||
|
||||
if (error.canRetry) {
|
||||
actions.push({
|
||||
label: 'Try Again',
|
||||
onClick: () => window.location.reload(),
|
||||
variant: 'default' as const,
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: error.canRetry ? ('ghost' as const) : ('default' as const),
|
||||
})
|
||||
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='error'
|
||||
title='Invitation Error'
|
||||
description={errorMessage}
|
||||
description={error.message}
|
||||
icon='error'
|
||||
isExpiredError={isExpiredError}
|
||||
actions={[
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
]}
|
||||
isExpiredError={error.code === 'expired'}
|
||||
actions={actions}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
interface InviteLayoutProps {
|
||||
@@ -8,12 +9,13 @@ interface InviteLayoutProps {
|
||||
|
||||
export default function InviteLayout({ children }: InviteLayoutProps) {
|
||||
return (
|
||||
<div className='relative min-h-screen'>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
<Nav variant='auth' />
|
||||
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Mail,
|
||||
RotateCcw,
|
||||
ShieldX,
|
||||
UserPlus,
|
||||
Users2,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ArrowRight, ChevronRight, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
@@ -32,104 +23,53 @@ interface InviteStatusCardProps {
|
||||
isExpiredError?: boolean
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
userPlus: UserPlus,
|
||||
mail: Mail,
|
||||
users: Users2,
|
||||
error: ShieldX,
|
||||
success: CheckCircle2,
|
||||
warning: AlertCircle,
|
||||
}
|
||||
|
||||
const iconColorMap = {
|
||||
userPlus: 'text-[var(--brand-primary-hex)]',
|
||||
mail: 'text-[var(--brand-primary-hex)]',
|
||||
users: 'text-[var(--brand-primary-hex)]',
|
||||
error: 'text-red-500 dark:text-red-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
warning: 'text-yellow-600 dark:text-yellow-500',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
userPlus: 'bg-[var(--brand-primary-hex)]/10',
|
||||
mail: 'bg-[var(--brand-primary-hex)]/10',
|
||||
users: 'bg-[var(--brand-primary-hex)]/10',
|
||||
error: 'bg-red-50 dark:bg-red-950/20',
|
||||
success: 'bg-green-50 dark:bg-green-950/20',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-950/20',
|
||||
}
|
||||
|
||||
export function InviteStatusCard({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
icon: _icon,
|
||||
actions = [],
|
||||
isExpiredError = false,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter()
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
const [hoveredButtonIndex, setHoveredButtonIndex] = useState<number | null>(null)
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
checkCustomBrand()
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (type === 'loading') {
|
||||
return (
|
||||
<div className={`${soehne.className} space-y-6`}>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-medium text-[32px] text-black tracking-tight'>Loading</h1>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex w-full items-center justify-center py-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-[var(--brand-primary-hex)]' />
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href='mailto:help@sim.ai'
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const IconComponent = icon ? iconMap[icon] : null
|
||||
const iconColor = icon ? iconColorMap[icon] : ''
|
||||
const iconBg = icon ? iconBgMap[icon] : ''
|
||||
|
||||
return (
|
||||
<div className={`${soehne.className} space-y-6`}>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-medium text-[32px] text-black tracking-tight'>{title}</h1>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
@@ -148,28 +88,55 @@ export function InviteStatusCard({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'default'}
|
||||
className={
|
||||
(action.variant || 'default') === 'default'
|
||||
? `${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`
|
||||
: action.variant === 'outline'
|
||||
{actions.map((action, index) => {
|
||||
const isPrimary = (action.variant || 'default') === 'default'
|
||||
const isHovered = hoveredButtonIndex === index
|
||||
|
||||
if (isPrimary) {
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
onMouseEnter={() => setHoveredButtonIndex(index)}
|
||||
onMouseLeave={() => setHoveredButtonIndex(null)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{action.loading ? `${action.label}...` : action.label}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant}
|
||||
className={
|
||||
action.variant === 'outline'
|
||||
? 'w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
: 'w-full rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
{action.loading ? `${action.label}...` : action.label}
|
||||
</Button>
|
||||
))}
|
||||
}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
{action.loading ? `${action.label}...` : action.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
@@ -179,6 +146,6 @@ export function InviteStatusCard({
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
TimePicker,
|
||||
Tooltip,
|
||||
Trash,
|
||||
Trash2,
|
||||
@@ -125,6 +126,7 @@ export default function PlaygroundPage() {
|
||||
const [switchValue, setSwitchValue] = useState(false)
|
||||
const [checkboxValue, setCheckboxValue] = useState(false)
|
||||
const [sliderValue, setSliderValue] = useState([50])
|
||||
const [timeValue, setTimeValue] = useState('09:30')
|
||||
const [activeTab, setActiveTab] = useState('profile')
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
|
||||
@@ -491,6 +493,31 @@ export default function PlaygroundPage() {
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* TimePicker */}
|
||||
<Section title='TimePicker'>
|
||||
<VariantRow label='default'>
|
||||
<div className='w-48'>
|
||||
<TimePicker value={timeValue} onChange={setTimeValue} placeholder='Select time' />
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>{timeValue}</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='size sm'>
|
||||
<div className='w-48'>
|
||||
<TimePicker value='14:00' onChange={() => {}} placeholder='Small size' size='sm' />
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='no value'>
|
||||
<div className='w-48'>
|
||||
<TimePicker placeholder='Select time...' onChange={() => {}} />
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='disabled'>
|
||||
<div className='w-48'>
|
||||
<TimePicker value='09:00' disabled />
|
||||
</div>
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<Section title='Breadcrumb'>
|
||||
<Breadcrumb
|
||||
|
||||
@@ -39,11 +39,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 +74,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 +102,7 @@ export function ChunkContextMenu({
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{hasChunk ? (
|
||||
<>
|
||||
{onOpenInNewTab && (
|
||||
{!isMultiSelect && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenInNewTab()
|
||||
@@ -86,7 +112,7 @@ export function ChunkContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
{!isMultiSelect && onEdit && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onEdit()
|
||||
@@ -96,7 +122,7 @@ export function ChunkContextMenu({
|
||||
Edit
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onCopyContent && (
|
||||
{!isMultiSelect && onCopyContent && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopyContent()
|
||||
@@ -114,7 +140,7 @@ export function ChunkContextMenu({
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{isChunkEnabled ? 'Disable' : 'Enable'}
|
||||
{getToggleLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -107,14 +108,31 @@ interface DocumentProps {
|
||||
documentName?: string
|
||||
}
|
||||
|
||||
function getStatusBadgeStyles(enabled: boolean) {
|
||||
return enabled
|
||||
? 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}
|
||||
|
||||
function truncateContent(content: string, maxLength = 150): string {
|
||||
function truncateContent(content: string, maxLength = 150, searchQuery = ''): string {
|
||||
if (content.length <= maxLength) return content
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const searchTerms = searchQuery
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((term) => term.length > 0)
|
||||
.map((term) => term.toLowerCase())
|
||||
|
||||
for (const term of searchTerms) {
|
||||
const matchIndex = content.toLowerCase().indexOf(term)
|
||||
if (matchIndex !== -1) {
|
||||
const contextBefore = 30
|
||||
const start = Math.max(0, matchIndex - contextBefore)
|
||||
const end = Math.min(content.length, start + maxLength)
|
||||
|
||||
let result = content.substring(start, end)
|
||||
if (start > 0) result = `...${result}`
|
||||
if (end < content.length) result = `${result}...`
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${content.substring(0, maxLength)}...`
|
||||
}
|
||||
|
||||
@@ -655,13 +673,21 @@ export function Document({
|
||||
|
||||
/**
|
||||
* Handle right-click on a chunk row
|
||||
* If right-clicking on an unselected chunk, select only that chunk
|
||||
* If right-clicking on a selected chunk with multiple selections, keep all selections
|
||||
*/
|
||||
const handleChunkContextMenu = useCallback(
|
||||
(e: React.MouseEvent, chunk: ChunkData) => {
|
||||
const isCurrentlySelected = selectedChunks.has(chunk.id)
|
||||
|
||||
if (!isCurrentlySelected) {
|
||||
setSelectedChunks(new Set([chunk.id]))
|
||||
}
|
||||
|
||||
setContextMenuChunk(chunk)
|
||||
baseHandleContextMenu(e)
|
||||
},
|
||||
[baseHandleContextMenu]
|
||||
[selectedChunks, baseHandleContextMenu]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -946,106 +972,114 @@ export function Document({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
displayChunks.map((chunk: ChunkData) => (
|
||||
<TableRow
|
||||
key={chunk.id}
|
||||
className='cursor-pointer hover:bg-[var(--surface-2)]'
|
||||
onClick={() => handleChunkClick(chunk)}
|
||||
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
|
||||
>
|
||||
<TableCell
|
||||
className='w-[52px] py-[8px]'
|
||||
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
|
||||
displayChunks.map((chunk: ChunkData) => {
|
||||
const isSelected = selectedChunks.has(chunk.id)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={chunk.id}
|
||||
className={`${
|
||||
isSelected
|
||||
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
|
||||
} cursor-pointer`}
|
||||
onClick={() => handleChunkClick(chunk)}
|
||||
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectedChunks.has(chunk.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectChunk(chunk.id, checked as boolean)
|
||||
}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label={`Select chunk ${chunk.chunkIndex}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
|
||||
{chunk.chunkIndex}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={chunk.content}
|
||||
<TableCell
|
||||
className='w-[52px] py-[8px]'
|
||||
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
|
||||
>
|
||||
<SearchHighlight
|
||||
text={truncateContent(chunk.content)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
{chunk.tokenCount > 1000
|
||||
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
|
||||
: chunk.tokenCount}
|
||||
</TableCell>
|
||||
<TableCell className='w-[12%] px-[12px] py-[8px]'>
|
||||
<div className={getStatusBadgeStyles(chunk.enabled)}>
|
||||
{chunk.enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleEnabled(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
|
||||
>
|
||||
{chunk.enabled ? (
|
||||
<Circle className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<CircleOff className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to modify chunks'
|
||||
: chunk.enabled
|
||||
? 'Disable Chunk'
|
||||
: 'Enable Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteChunk(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to delete chunks'
|
||||
: 'Delete Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
<div className='flex items-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectedChunks.has(chunk.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectChunk(chunk.id, checked as boolean)
|
||||
}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label={`Select chunk ${chunk.chunkIndex}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
|
||||
{chunk.chunkIndex}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={chunk.content}
|
||||
>
|
||||
<SearchHighlight
|
||||
text={truncateContent(chunk.content, 150, searchQuery)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
{chunk.tokenCount > 1000
|
||||
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
|
||||
: chunk.tokenCount.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className='w-[12%] px-[12px] py-[8px]'>
|
||||
<Badge variant={chunk.enabled ? 'green' : 'gray'} size='sm'>
|
||||
{chunk.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleEnabled(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
|
||||
>
|
||||
{chunk.enabled ? (
|
||||
<Circle className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<CircleOff className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to modify chunks'
|
||||
: chunk.enabled
|
||||
? 'Disable Chunk'
|
||||
: 'Enable Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteChunk(chunk.id)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit}
|
||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{!userPermissions.canEdit
|
||||
? 'Write permission required to delete chunks'
|
||||
: 'Delete Chunk'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -1206,8 +1240,11 @@ export function Document({
|
||||
onClose={handleContextMenuClose}
|
||||
hasChunk={contextMenuChunk !== null}
|
||||
isChunkEnabled={contextMenuChunk?.enabled ?? true}
|
||||
selectedCount={selectedChunks.size}
|
||||
enabledCount={enabledCount}
|
||||
disabledCount={disabledCount}
|
||||
onOpenInNewTab={
|
||||
contextMenuChunk
|
||||
contextMenuChunk && selectedChunks.size === 1
|
||||
? () => {
|
||||
const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}`
|
||||
window.open(url, '_blank')
|
||||
@@ -1215,7 +1252,7 @@ export function Document({
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
contextMenuChunk
|
||||
contextMenuChunk && selectedChunks.size === 1
|
||||
? () => {
|
||||
setSelectedChunk(contextMenuChunk)
|
||||
setIsModalOpen(true)
|
||||
@@ -1223,7 +1260,7 @@ export function Document({
|
||||
: undefined
|
||||
}
|
||||
onCopyContent={
|
||||
contextMenuChunk
|
||||
contextMenuChunk && selectedChunks.size === 1
|
||||
? () => {
|
||||
navigator.clipboard.writeText(contextMenuChunk.content)
|
||||
}
|
||||
@@ -1231,12 +1268,22 @@ export function Document({
|
||||
}
|
||||
onToggleEnabled={
|
||||
contextMenuChunk && userPermissions.canEdit
|
||||
? () => handleToggleEnabled(contextMenuChunk.id)
|
||||
? selectedChunks.size > 1
|
||||
? () => {
|
||||
if (disabledCount > 0) {
|
||||
handleBulkEnable()
|
||||
} else {
|
||||
handleBulkDisable()
|
||||
}
|
||||
}
|
||||
: () => handleToggleEnabled(contextMenuChunk.id)
|
||||
: undefined
|
||||
}
|
||||
onDelete={
|
||||
contextMenuChunk && userPermissions.canEdit
|
||||
? () => handleDeleteChunk(contextMenuChunk.id)
|
||||
? selectedChunks.size > 1
|
||||
? handleBulkDelete
|
||||
: () => handleDeleteChunk(contextMenuChunk.id)
|
||||
: undefined
|
||||
}
|
||||
onAddChunk={
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -47,10 +48,12 @@ import {
|
||||
AddDocumentsModal,
|
||||
BaseTagsModal,
|
||||
DocumentContextMenu,
|
||||
RenameDocumentModal,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import {
|
||||
useKnowledgeBase,
|
||||
useKnowledgeBaseDocuments,
|
||||
@@ -404,6 +407,7 @@ export function KnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseName: passedKnowledgeBaseName,
|
||||
}: KnowledgeBaseProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||
@@ -432,6 +436,8 @@ export function KnowledgeBase({
|
||||
const [sortBy, setSortBy] = useState<DocumentSortField>('uploadedAt')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -699,6 +705,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 +1028,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 +1279,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 +1628,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 +1661,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 +1678,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 +1711,9 @@ export function KnowledgeBase({
|
||||
}
|
||||
onDelete={
|
||||
contextMenuDocument && userPermissions.canEdit
|
||||
? () => handleDeleteDocument(contextMenuDocument.id)
|
||||
? selectedDocuments.size > 1
|
||||
? handleBulkDelete
|
||||
: () => handleDeleteDocument(contextMenuDocument.id)
|
||||
: undefined
|
||||
}
|
||||
onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined}
|
||||
|
||||
@@ -11,6 +11,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 +43,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 +68,7 @@ export function DocumentContextMenu({
|
||||
menuRef,
|
||||
onClose,
|
||||
onOpenInNewTab,
|
||||
onRename,
|
||||
onToggleEnabled,
|
||||
onViewTags,
|
||||
onDelete,
|
||||
@@ -64,7 +79,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 +107,7 @@ export function DocumentContextMenu({
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{hasDocument ? (
|
||||
<>
|
||||
{onOpenInNewTab && (
|
||||
{!isMultiSelect && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenInNewTab()
|
||||
@@ -89,7 +117,17 @@ export function DocumentContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{hasTags && onViewTags && (
|
||||
{!isMultiSelect && onRename && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onRename()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!isMultiSelect && hasTags && onViewTags && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewTags()
|
||||
@@ -107,7 +145,7 @@ export function DocumentContextMenu({
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{isDocumentEnabled ? 'Disable' : 'Enable'}
|
||||
{getToggleLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
|
||||
@@ -2,3 +2,4 @@ export { ActionBar } from './action-bar/action-bar'
|
||||
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
|
||||
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
|
||||
export { DocumentContextMenu } from './document-context-menu'
|
||||
export { RenameDocumentModal } from './rename-document-modal'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { RenameDocumentModal } from './rename-document-modal'
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const logger = createLogger('RenameDocumentModal')
|
||||
|
||||
interface RenameDocumentModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
documentId: string
|
||||
initialName: string
|
||||
onSave: (documentId: string, newName: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for renaming a document.
|
||||
* Only changes the display name, not the underlying storage key.
|
||||
*/
|
||||
export function RenameDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentId,
|
||||
initialName,
|
||||
onSave,
|
||||
}: RenameDocumentModalProps) {
|
||||
const [name, setName] = useState(initialName)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, initialName])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const trimmedName = name.trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
setError('Name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedName === initialName) {
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSave(documentId, trimmedName)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Error renaming document:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename document')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename Document</ModalHeader>
|
||||
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='document-name'>Name</Label>
|
||||
<Input
|
||||
id='document-name'
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder='Enter document name'
|
||||
className={cn(error && 'border-[var(--text-error)]')}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{error ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex flex-shrink-0 gap-[8px]'>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => onOpenChange(false)}
|
||||
type='button'
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
|
||||
{isSubmitting ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -3,5 +3,6 @@ export { LogDetails } from './log-details'
|
||||
export { FileCards } from './log-details/components/file-download'
|
||||
export { FrozenCanvas } from './log-details/components/frozen-canvas'
|
||||
export { TraceSpans } from './log-details/components/trace-spans'
|
||||
export { LogRowContextMenu } from './log-row-context-menu'
|
||||
export { LogsList } from './logs-list'
|
||||
export { AutocompleteSearch, LogsToolbar, NotificationSettings } from './logs-toolbar'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { LogRowContextMenu } from './log-row-context-menu'
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
interface LogRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
log: WorkflowLog | null
|
||||
onCopyExecutionId: () => void
|
||||
onOpenWorkflow: () => void
|
||||
onToggleWorkflowFilter: () => void
|
||||
onClearAllFilters: () => void
|
||||
isFilteredByThisWorkflow: boolean
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for log rows.
|
||||
* Provides quick actions for copying data, navigation, and filtering.
|
||||
*/
|
||||
export function LogRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
log,
|
||||
onCopyExecutionId,
|
||||
onOpenWorkflow,
|
||||
onToggleWorkflowFilter,
|
||||
onClearAllFilters,
|
||||
isFilteredByThisWorkflow,
|
||||
hasActiveFilters,
|
||||
}: LogRowContextMenuProps) {
|
||||
const hasExecutionId = Boolean(log?.executionId)
|
||||
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<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 Execution ID */}
|
||||
<PopoverItem
|
||||
disabled={!hasExecutionId}
|
||||
onClick={() => {
|
||||
onCopyExecutionId()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Copy Execution ID
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Workflow */}
|
||||
<PopoverItem
|
||||
disabled={!hasWorkflow}
|
||||
onClick={() => {
|
||||
onOpenWorkflow()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open Workflow
|
||||
</PopoverItem>
|
||||
|
||||
{/* Filter by Workflow - only show when not already filtered by this workflow */}
|
||||
{!isFilteredByThisWorkflow && (
|
||||
<PopoverItem
|
||||
disabled={!hasWorkflow}
|
||||
onClick={() => {
|
||||
onToggleWorkflowFilter()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Filter by Workflow
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Clear All Filters - show when any filters are active */}
|
||||
{hasActiveFilters && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearAllFilters()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
getDisplayStatus,
|
||||
LOG_COLUMNS,
|
||||
StatusBadge,
|
||||
TriggerBadge,
|
||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
@@ -21,6 +22,7 @@ interface LogRowProps {
|
||||
log: WorkflowLog
|
||||
isSelected: boolean
|
||||
onClick: (log: WorkflowLog) => void
|
||||
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
|
||||
}
|
||||
|
||||
@@ -29,11 +31,21 @@ interface LogRowProps {
|
||||
* Uses shallow comparison for the log object.
|
||||
*/
|
||||
const LogRow = memo(
|
||||
function LogRow({ log, isSelected, onClick, selectedRowRef }: LogRowProps) {
|
||||
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
|
||||
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
|
||||
|
||||
const handleClick = useCallback(() => onClick(log), [onClick, log])
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault()
|
||||
onContextMenu(e, log)
|
||||
}
|
||||
},
|
||||
[onContextMenu, log]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isSelected ? selectedRowRef : null}
|
||||
@@ -42,25 +54,28 @@ const LogRow = memo(
|
||||
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className='flex flex-1 items-center'>
|
||||
{/* Date */}
|
||||
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
<span
|
||||
className={`${LOG_COLUMNS.date.width} ${LOG_COLUMNS.date.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
|
||||
>
|
||||
{formattedDate.compactDate}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
<span
|
||||
className={`${LOG_COLUMNS.time.width} ${LOG_COLUMNS.time.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
|
||||
>
|
||||
{formattedDate.compactTime}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<div className='w-[12%] min-w-[100px]'>
|
||||
<div className={`${LOG_COLUMNS.status.width} ${LOG_COLUMNS.status.minWidth}`}>
|
||||
<StatusBadge status={getDisplayStatus(log.status)} />
|
||||
</div>
|
||||
|
||||
{/* Workflow */}
|
||||
<div className='flex w-[22%] min-w-[140px] items-center gap-[8px] pr-[8px]'>
|
||||
<div
|
||||
className={`flex ${LOG_COLUMNS.workflow.width} ${LOG_COLUMNS.workflow.minWidth} items-center gap-[8px] pr-[8px]`}
|
||||
>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: log.workflow?.color }}
|
||||
@@ -70,13 +85,13 @@ const LogRow = memo(
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
<span
|
||||
className={`${LOG_COLUMNS.cost.width} ${LOG_COLUMNS.cost.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
|
||||
>
|
||||
{typeof log.cost?.total === 'number' ? `$${log.cost.total.toFixed(4)}` : '—'}
|
||||
</span>
|
||||
|
||||
{/* Trigger */}
|
||||
<div className='w-[14%] min-w-[110px]'>
|
||||
<div className={`${LOG_COLUMNS.trigger.width} ${LOG_COLUMNS.trigger.minWidth}`}>
|
||||
{log.trigger ? (
|
||||
<TriggerBadge trigger={log.trigger} />
|
||||
) : (
|
||||
@@ -84,8 +99,7 @@ const LogRow = memo(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className='w-[20%] min-w-[100px]'>
|
||||
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
|
||||
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
|
||||
{formatDuration(log.duration) || '—'}
|
||||
</Badge>
|
||||
@@ -125,6 +139,7 @@ interface RowProps {
|
||||
logs: WorkflowLog[]
|
||||
selectedLogId: string | null
|
||||
onLogClick: (log: WorkflowLog) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
|
||||
isFetchingNextPage: boolean
|
||||
loaderRef: React.RefObject<HTMLDivElement | null>
|
||||
@@ -140,11 +155,11 @@ function Row({
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
isFetchingNextPage,
|
||||
loaderRef,
|
||||
}: RowComponentProps<RowProps>) {
|
||||
// Show loader for the last item if loading more
|
||||
if (index >= logs.length) {
|
||||
return (
|
||||
<div style={style} className='flex items-center justify-center'>
|
||||
@@ -171,6 +186,7 @@ function Row({
|
||||
log={log}
|
||||
isSelected={isSelected}
|
||||
onClick={onLogClick}
|
||||
onContextMenu={onLogContextMenu}
|
||||
selectedRowRef={isSelected ? selectedRowRef : null}
|
||||
/>
|
||||
</div>
|
||||
@@ -181,6 +197,7 @@ export interface LogsListProps {
|
||||
logs: WorkflowLog[]
|
||||
selectedLogId: string | null
|
||||
onLogClick: (log: WorkflowLog) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
|
||||
hasNextPage: boolean
|
||||
isFetchingNextPage: boolean
|
||||
@@ -198,6 +215,7 @@ export function LogsList({
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
@@ -208,7 +226,6 @@ export function LogsList({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [listHeight, setListHeight] = useState(400)
|
||||
|
||||
// Measure container height for virtualization
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
@@ -226,7 +243,6 @@ export function LogsList({
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Handle infinite scroll when nearing the end of the list
|
||||
const handleRowsRendered = useCallback(
|
||||
({ stopIndex }: { startIndex: number; stopIndex: number }) => {
|
||||
const threshold = logs.length - 10
|
||||
@@ -237,20 +253,27 @@ export function LogsList({
|
||||
[logs.length, hasNextPage, isFetchingNextPage, onLoadMore]
|
||||
)
|
||||
|
||||
// Calculate total item count including loader row
|
||||
const itemCount = hasNextPage ? logs.length + 1 : logs.length
|
||||
|
||||
// Row props passed to each row component
|
||||
const rowProps = useMemo<RowProps>(
|
||||
() => ({
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
isFetchingNextPage,
|
||||
loaderRef,
|
||||
}),
|
||||
[logs, selectedLogId, onLogClick, selectedRowRef, isFetchingNextPage, loaderRef]
|
||||
[
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
isFetchingNextPage,
|
||||
loaderRef,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ALL_TRIGGER_TYPES, type TriggerType } from '@/lib/logs/types'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import {
|
||||
type NotificationSubscription,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
} from '@/hooks/queries/notifications'
|
||||
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
|
||||
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
|
||||
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
import { SlackChannelSelector } from './components/slack-channel-selector'
|
||||
import { WorkflowSelector } from './components/workflow-selector'
|
||||
|
||||
@@ -133,7 +133,7 @@ export function NotificationSettings({
|
||||
workflowIds: [] as string[],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'] as LogLevel[],
|
||||
triggerFilter: [...ALL_TRIGGER_TYPES] as TriggerType[],
|
||||
triggerFilter: [...CORE_TRIGGER_TYPES] as CoreTriggerType[],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
@@ -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,18 +196,12 @@ export function NotificationSettings({
|
||||
[subscriptions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !hasSubscriptions && !editingId) {
|
||||
setShowForm(true)
|
||||
}
|
||||
}, [isLoading, hasSubscriptions, editingId, activeTab])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
workflowIds: [],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'],
|
||||
triggerFilter: [...ALL_TRIGGER_TYPES],
|
||||
triggerFilter: [...CORE_TRIGGER_TYPES],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
@@ -516,7 +514,7 @@ export function NotificationSettings({
|
||||
workflowIds: subscription.workflowIds || [],
|
||||
allWorkflows: subscription.allWorkflows,
|
||||
levelFilter: subscription.levelFilter as LogLevel[],
|
||||
triggerFilter: subscription.triggerFilter as TriggerType[],
|
||||
triggerFilter: subscription.triggerFilter as CoreTriggerType[],
|
||||
includeFinalOutput: subscription.includeFinalOutput,
|
||||
includeTraceSpans: subscription.includeTraceSpans,
|
||||
includeRateLimits: subscription.includeRateLimits,
|
||||
@@ -849,14 +847,14 @@ export function NotificationSettings({
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[var(--text-secondary)]'>Trigger Type Filters</Label>
|
||||
<Combobox
|
||||
options={ALL_TRIGGER_TYPES.map((trigger) => ({
|
||||
options={CORE_TRIGGER_TYPES.map((trigger) => ({
|
||||
label: trigger.charAt(0).toUpperCase() + trigger.slice(1),
|
||||
value: trigger,
|
||||
}))}
|
||||
multiSelect
|
||||
multiSelectValues={formData.triggerFilter}
|
||||
onMultiSelectChange={(values) => {
|
||||
setFormData({ ...formData, triggerFilter: values as TriggerType[] })
|
||||
setFormData({ ...formData, triggerFilter: values as CoreTriggerType[] })
|
||||
setFormErrors({ ...formErrors, triggerFilter: '' })
|
||||
}}
|
||||
placeholder='Select trigger types...'
|
||||
@@ -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
|
||||
|
||||
@@ -17,15 +17,15 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasActiveFilters } from '@/lib/logs/filters'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { AutocompleteSearch } from './components/search'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
|
||||
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
|
||||
{ value: 'All time', label: 'All time' },
|
||||
{ value: 'Past 30 minutes', label: 'Past 30 minutes' },
|
||||
@@ -182,6 +182,7 @@ export function LogsToolbar({
|
||||
endDate,
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
resetFilters,
|
||||
} = useFilterStore()
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
@@ -346,23 +347,23 @@ export function LogsToolbar({
|
||||
setDatePickerOpen(false)
|
||||
}, [timeRange, startDate, previousTimeRange, setTimeRange])
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
level !== 'all' ||
|
||||
workflowIds.length > 0 ||
|
||||
folderIds.length > 0 ||
|
||||
triggers.length > 0 ||
|
||||
timeRange !== 'All time'
|
||||
)
|
||||
}, [level, workflowIds, folderIds, triggers, timeRange])
|
||||
const filtersActive = useMemo(
|
||||
() =>
|
||||
hasActiveFilters({
|
||||
timeRange,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
searchQuery,
|
||||
}),
|
||||
[timeRange, level, workflowIds, folderIds, triggers, searchQuery]
|
||||
)
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setLevel('all')
|
||||
setWorkflowIds([])
|
||||
setFolderIds([])
|
||||
setTriggers([])
|
||||
clearDateRange()
|
||||
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, clearDateRange])
|
||||
resetFilters()
|
||||
onSearchQueryChange('')
|
||||
}, [resetFilters, onSearchQueryChange])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[19px]'>
|
||||
@@ -462,7 +463,7 @@ export function LogsToolbar({
|
||||
</div>
|
||||
<div className='ml-auto flex items-center gap-[8px]'>
|
||||
{/* Clear Filters Button */}
|
||||
{hasActiveFilters && (
|
||||
{filtersActive && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={handleClearFilters}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import {
|
||||
getEndDateFromTimeRange,
|
||||
getStartDateFromTimeRange,
|
||||
hasActiveFilters,
|
||||
} from '@/lib/logs/filters'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
|
||||
@@ -12,7 +16,15 @@ import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
||||
import { Dashboard, LogDetails, LogsList, LogsToolbar, NotificationSettings } from './components'
|
||||
import {
|
||||
Dashboard,
|
||||
LogDetails,
|
||||
LogRowContextMenu,
|
||||
LogsList,
|
||||
LogsToolbar,
|
||||
NotificationSettings,
|
||||
} from './components'
|
||||
import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
|
||||
|
||||
const LOGS_PER_PAGE = 50 as const
|
||||
const REFRESH_SPINNER_DURATION_MS = 1000 as const
|
||||
@@ -35,10 +47,12 @@ export default function Logs() {
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
setWorkflowIds,
|
||||
setSearchQuery: setStoreSearchQuery,
|
||||
triggers,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
resetFilters,
|
||||
} = useFilterStore()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,6 +85,11 @@ export default function Logs() {
|
||||
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const logFilters = useMemo(
|
||||
() => ({
|
||||
timeRange,
|
||||
@@ -216,6 +235,56 @@ export default function Logs() {
|
||||
prevSelectedLogRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
|
||||
e.preventDefault()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setContextMenuLog(log)
|
||||
setContextMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCopyExecutionId = useCallback(() => {
|
||||
if (contextMenuLog?.executionId) {
|
||||
navigator.clipboard.writeText(contextMenuLog.executionId)
|
||||
}
|
||||
}, [contextMenuLog])
|
||||
|
||||
const handleOpenWorkflow = useCallback(() => {
|
||||
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
|
||||
if (wfId) {
|
||||
window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank')
|
||||
}
|
||||
}, [contextMenuLog, workspaceId])
|
||||
|
||||
const handleToggleWorkflowFilter = useCallback(() => {
|
||||
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
|
||||
if (!wfId) return
|
||||
|
||||
if (workflowIds.length === 1 && workflowIds[0] === wfId) {
|
||||
setWorkflowIds([])
|
||||
} else {
|
||||
setWorkflowIds([wfId])
|
||||
}
|
||||
}, [contextMenuLog, workflowIds, setWorkflowIds])
|
||||
|
||||
const handleClearAllFilters = useCallback(() => {
|
||||
resetFilters()
|
||||
setSearchQuery('')
|
||||
}, [resetFilters, setSearchQuery])
|
||||
|
||||
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
|
||||
const isFilteredByThisWorkflow = Boolean(
|
||||
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
|
||||
)
|
||||
|
||||
const filtersActive = hasActiveFilters({
|
||||
timeRange,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
searchQuery: debouncedSearchQuery,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRowRef.current) {
|
||||
selectedRowRef.current.scrollIntoView({
|
||||
@@ -400,27 +469,17 @@ export default function Logs() {
|
||||
{/* Table header */}
|
||||
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px] dark:bg-[var(--surface-3)]'>
|
||||
<div className='flex items-center'>
|
||||
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Date
|
||||
</span>
|
||||
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Time
|
||||
</span>
|
||||
<span className='w-[12%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Status
|
||||
</span>
|
||||
<span className='w-[22%] min-w-[140px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow
|
||||
</span>
|
||||
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Cost
|
||||
</span>
|
||||
<span className='w-[14%] min-w-[110px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Trigger
|
||||
</span>
|
||||
<span className='w-[20%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Duration
|
||||
</span>
|
||||
{LOG_COLUMN_ORDER.map((key) => {
|
||||
const col = LOG_COLUMNS[key]
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={`${col.width} ${col.minWidth} font-medium text-[12px] text-[var(--text-tertiary)]`}
|
||||
>
|
||||
{col.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -452,6 +511,7 @@ export default function Logs() {
|
||||
logs={logs}
|
||||
selectedLogId={selectedLog?.id ?? null}
|
||||
onLogClick={handleLogClick}
|
||||
onLogContextMenu={handleLogContextMenu}
|
||||
selectedRowRef={selectedRowRef}
|
||||
hasNextPage={logsQuery.hasNextPage ?? false}
|
||||
isFetchingNextPage={logsQuery.isFetchingNextPage}
|
||||
@@ -481,6 +541,20 @@ export default function Logs() {
|
||||
open={isNotificationSettingsOpen}
|
||||
onOpenChange={setIsNotificationSettingsOpen}
|
||||
/>
|
||||
|
||||
<LogRowContextMenu
|
||||
isOpen={contextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
log={contextMenuLog}
|
||||
onCopyExecutionId={handleCopyExecutionId}
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
onToggleWorkflowFilter={handleToggleWorkflowFilter}
|
||||
onClearAllFilters={handleClearAllFilters}
|
||||
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
|
||||
hasActiveFilters={filtersActive}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,32 @@ import { format } from 'date-fns'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
/** Column configuration for logs table - shared between header and rows */
|
||||
export const LOG_COLUMNS = {
|
||||
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
|
||||
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
|
||||
status: { width: 'w-[12%]', minWidth: 'min-w-[100px]', label: 'Status' },
|
||||
workflow: { width: 'w-[22%]', minWidth: 'min-w-[140px]', label: 'Workflow' },
|
||||
cost: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Cost' },
|
||||
trigger: { width: 'w-[14%]', minWidth: 'min-w-[110px]', label: 'Trigger' },
|
||||
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
|
||||
} as const
|
||||
|
||||
/** Type-safe column key derived from LOG_COLUMNS */
|
||||
export type LogColumnKey = keyof typeof LOG_COLUMNS
|
||||
|
||||
/** Ordered list of column keys for rendering table headers */
|
||||
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
|
||||
'date',
|
||||
'time',
|
||||
'status',
|
||||
'workflow',
|
||||
'cost',
|
||||
'trigger',
|
||||
'duration',
|
||||
] as const
|
||||
|
||||
/** Possible execution status values for workflow logs */
|
||||
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Badge,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
@@ -468,7 +468,7 @@ export function OutputSelect({
|
||||
disablePortal={disablePopoverPortal}
|
||||
>
|
||||
<div className='space-y-[2px]'>
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
|
||||
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
|
||||
|
||||
const firstOutput = outputs[0]
|
||||
@@ -489,12 +489,10 @@ export function OutputSelect({
|
||||
|
||||
return (
|
||||
<div key={blockName}>
|
||||
<PopoverSection>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<TagIcon icon={blockIcon} color={blockColor} />
|
||||
<span>{blockName}</span>
|
||||
</div>
|
||||
</PopoverSection>
|
||||
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
|
||||
<TagIcon icon={blockIcon} color={blockColor} />
|
||||
<span className='font-medium text-[13px]'>{blockName}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{outputs.map((output, localIndex) => {
|
||||
@@ -509,14 +507,13 @@ export function OutputSelect({
|
||||
onClick={() => handleOutputSelection(output.label)}
|
||||
onMouseEnter={() => setHighlightedIndex(globalIndex)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
|
||||
{output.path}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
|
||||
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { BlockContextMenuProps } from './types'
|
||||
|
||||
/**
|
||||
@@ -48,7 +54,13 @@ export function BlockContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -59,7 +71,7 @@ export function BlockContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Copy */}
|
||||
{/* Clipboard actions */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
onClick={() => {
|
||||
@@ -70,8 +82,6 @@ export function BlockContextMenu({
|
||||
<span>Copy</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘C</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Paste */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !hasClipboard}
|
||||
@@ -83,8 +93,6 @@ export function BlockContextMenu({
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Duplicate - hide for starter blocks */}
|
||||
{!hasStarterBlock && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -97,20 +105,8 @@ export function BlockContextMenu({
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Delete</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌫</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Enable/Disable - hide if all blocks are notes */}
|
||||
{/* Toggle and edit actions */}
|
||||
{!allNoteBlocks && <PopoverDivider />}
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -122,8 +118,6 @@ export function BlockContextMenu({
|
||||
{getToggleEnabledLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Flip Handles - hide if all blocks are notes */}
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -135,8 +129,6 @@ export function BlockContextMenu({
|
||||
Flip Handles
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Remove from Subflow - only show when applicable */}
|
||||
{canRemoveFromSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -149,7 +141,8 @@ export function BlockContextMenu({
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Rename - only for single block, not subflows */}
|
||||
{/* Single block actions */}
|
||||
{isSingleBlock && <PopoverDivider />}
|
||||
{isSingleBlock && !isSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
@@ -161,8 +154,6 @@ export function BlockContextMenu({
|
||||
Rename
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Open Editor - only for single block */}
|
||||
{isSingleBlock && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
@@ -173,6 +164,20 @@ export function BlockContextMenu({
|
||||
Open Editor
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Delete</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌫</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { PaneContextMenuProps } from './types'
|
||||
|
||||
/**
|
||||
@@ -28,7 +34,13 @@ export function PaneContextMenu({
|
||||
canRedo = false,
|
||||
}: PaneContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -39,7 +51,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Undo */}
|
||||
{/* History actions */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !canUndo}
|
||||
@@ -51,8 +63,6 @@ export function PaneContextMenu({
|
||||
<span>Undo</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘Z</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Redo */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !canRedo}
|
||||
@@ -65,7 +75,8 @@ export function PaneContextMenu({
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘⇧Z</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Paste */}
|
||||
{/* Edit and creation actions */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !hasClipboard}
|
||||
@@ -77,8 +88,6 @@ export function PaneContextMenu({
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Add Block */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
@@ -90,8 +99,6 @@ export function PaneContextMenu({
|
||||
<span>Add Block</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘K</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Auto-layout */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
@@ -104,7 +111,8 @@ export function PaneContextMenu({
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⇧L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Logs */}
|
||||
{/* Navigation actions */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
className='group'
|
||||
onClick={() => {
|
||||
@@ -115,8 +123,6 @@ export function PaneContextMenu({
|
||||
<span>Open Logs</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Variables */}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenVariables()
|
||||
@@ -125,8 +131,6 @@ export function PaneContextMenu({
|
||||
>
|
||||
Variables
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Chat */}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenChat()
|
||||
@@ -136,7 +140,8 @@ export function PaneContextMenu({
|
||||
Open Chat
|
||||
</PopoverItem>
|
||||
|
||||
{/* Invite to Workspace - admin only */}
|
||||
{/* Admin action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
disabled={disableAdmin}
|
||||
onClick={() => {
|
||||
|
||||
@@ -89,7 +89,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
|
||||
<span className='text-sm'>{href}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -276,7 +276,7 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
||||
const mode = useCopilotStore.getState().mode
|
||||
const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name)
|
||||
if (
|
||||
mode === 'build' &&
|
||||
(mode === 'build' || mode === 'superagent') &&
|
||||
isIntegrationTool(toolCall.name) &&
|
||||
toolCall.state === 'pending' &&
|
||||
!isAutoAllowed
|
||||
@@ -564,11 +564,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
|
||||
// Allow rendering if:
|
||||
// 1. Tool is in CLASS_TOOL_METADATA (client tools), OR
|
||||
// 2. We're in build mode (integration tools are executed server-side)
|
||||
// 2. We're in build or superagent mode (integration tools are executed server-side)
|
||||
const isClientTool = !!CLASS_TOOL_METADATA[toolCall.name]
|
||||
const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool
|
||||
const isIntegrationToolInAgentMode = (mode === 'build' || mode === 'superagent') && !isClientTool
|
||||
|
||||
if (!isClientTool && !isIntegrationToolInBuildMode) {
|
||||
if (!isClientTool && !isIntegrationToolInAgentMode) {
|
||||
return null
|
||||
}
|
||||
const isExpandableTool =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ListTree, MessageSquare, Package } from 'lucide-react'
|
||||
import { ListTree, MessageSquare, Package, Zap } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Popover,
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface ModeSelectorProps {
|
||||
/** Current mode - 'ask', 'build', or 'plan' */
|
||||
mode: 'ask' | 'build' | 'plan'
|
||||
/** Current mode - 'ask', 'build', 'plan', or 'superagent' */
|
||||
mode: 'ask' | 'build' | 'plan' | 'superagent'
|
||||
/** Callback when mode changes */
|
||||
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
|
||||
onModeChange?: (mode: 'ask' | 'build' | 'plan' | 'superagent') => void
|
||||
/** Whether the input is near the top of viewport (affects dropdown direction) */
|
||||
isNearTop: boolean
|
||||
/** Whether the selector is disabled */
|
||||
@@ -42,6 +42,9 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe
|
||||
if (mode === 'plan') {
|
||||
return <ListTree className='h-3 w-3' />
|
||||
}
|
||||
if (mode === 'superagent') {
|
||||
return <Zap className='h-3 w-3' />
|
||||
}
|
||||
return <Package className='h-3 w-3' />
|
||||
}
|
||||
|
||||
@@ -52,10 +55,13 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe
|
||||
if (mode === 'plan') {
|
||||
return 'Plan'
|
||||
}
|
||||
if (mode === 'superagent') {
|
||||
return 'Superagent'
|
||||
}
|
||||
return 'Build'
|
||||
}
|
||||
|
||||
const handleSelect = (selectedMode: 'ask' | 'build' | 'plan') => {
|
||||
const handleSelect = (selectedMode: 'ask' | 'build' | 'plan' | 'superagent') => {
|
||||
onModeChange?.(selectedMode)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ interface UserInputProps {
|
||||
isAborting?: boolean
|
||||
placeholder?: string
|
||||
className?: string
|
||||
mode?: 'ask' | 'build' | 'plan'
|
||||
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
|
||||
mode?: 'ask' | 'build' | 'plan' | 'superagent'
|
||||
onModeChange?: (mode: 'ask' | 'build' | 'plan' | 'superagent') => void
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
panelWidth?: number
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Button } from '@/components/emcn'
|
||||
interface WelcomeProps {
|
||||
/** Callback when a suggested question is clicked */
|
||||
onQuestionClick?: (question: string) => void
|
||||
/** Current copilot mode ('ask' for Q&A, 'plan' for planning, 'build' for workflow building) */
|
||||
mode?: 'ask' | 'build' | 'plan'
|
||||
/** Current copilot mode ('ask' for Q&A, 'plan' for planning, 'build' for workflow building, 'superagent' for full access) */
|
||||
mode?: 'ask' | 'build' | 'plan' | 'superagent'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,8 @@ const logger = createLogger('Copilot')
|
||||
interface CopilotProps {
|
||||
/** Width of the copilot panel in pixels */
|
||||
panelWidth: number
|
||||
/** If true, runs in standalone mode without workflow context (for superagent) */
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +69,7 @@ interface CopilotRef {
|
||||
* Copilot component - AI-powered assistant for workflow management
|
||||
* Provides chat interface, message history, and intelligent workflow suggestions
|
||||
*/
|
||||
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref) => {
|
||||
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, standalone = false }, ref) => {
|
||||
const userInputRef = useRef<UserInputRef>(null)
|
||||
const copilotContainerRef = useRef<HTMLDivElement>(null)
|
||||
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
||||
@@ -122,6 +124,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
standalone,
|
||||
})
|
||||
|
||||
// Handle scroll management
|
||||
@@ -298,7 +301,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
||||
if (!query || isSendingMessage || !activeWorkflowId) return
|
||||
if (!query || isSendingMessage || (!activeWorkflowId && !standalone)) return
|
||||
|
||||
if (showPlanTodos) {
|
||||
const store = useCopilotStore.getState()
|
||||
@@ -316,7 +319,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
logger.error('Failed to send message:', error)
|
||||
}
|
||||
},
|
||||
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
|
||||
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos, standalone]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -487,11 +490,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
ref={userInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onAbort={handleAbort}
|
||||
disabled={!activeWorkflowId}
|
||||
disabled={!activeWorkflowId && !standalone}
|
||||
isLoading={isSendingMessage}
|
||||
isAborting={isAborting}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
onModeChange={standalone ? undefined : setMode}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
panelWidth={panelWidth}
|
||||
@@ -594,11 +597,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
ref={userInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onAbort={handleAbort}
|
||||
disabled={!activeWorkflowId}
|
||||
disabled={!activeWorkflowId && !standalone}
|
||||
isLoading={isSendingMessage}
|
||||
isAborting={isAborting}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
onModeChange={standalone ? undefined : setMode}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
panelWidth={panelWidth}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface UseCopilotInitializationProps {
|
||||
loadAutoAllowedTools: () => Promise<void>
|
||||
currentChat: any
|
||||
isSendingMessage: boolean
|
||||
/** If true, initializes without requiring a workflowId (for standalone agent mode) */
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
standalone = false,
|
||||
} = props
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
@@ -46,6 +49,14 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
* Never loads during message streaming to prevent interrupting active conversations
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Standalone mode: initialize immediately without workflow
|
||||
if (standalone && !hasMountedRef.current && !isSendingMessage) {
|
||||
hasMountedRef.current = true
|
||||
setIsInitialized(true)
|
||||
logger.info('Standalone mode initialized')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) {
|
||||
hasMountedRef.current = true
|
||||
setIsInitialized(false)
|
||||
@@ -55,7 +66,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
// Use false to let the store decide if a reload is needed based on cache
|
||||
loadChats(false)
|
||||
}
|
||||
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage])
|
||||
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage, standalone])
|
||||
|
||||
/**
|
||||
* Initialize the component - only on mount and genuine workflow changes
|
||||
@@ -63,6 +74,9 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
* Never reloads during message streaming to preserve active conversations
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Skip workflow tracking in standalone mode
|
||||
if (standalone) return
|
||||
|
||||
// Handle genuine workflow changes (not initial mount, not same workflow)
|
||||
// Only reload if not currently streaming to avoid interrupting conversations
|
||||
if (
|
||||
@@ -100,19 +114,23 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
isSendingMessage,
|
||||
standalone,
|
||||
])
|
||||
|
||||
/**
|
||||
* Fetch context usage when component is initialized and has a current chat
|
||||
*/
|
||||
useEffect(() => {
|
||||
// In standalone mode, skip context usage fetch (no workflow context)
|
||||
if (standalone) return
|
||||
|
||||
if (isInitialized && currentChat?.id && activeWorkflowId) {
|
||||
logger.info('[Copilot] Component initialized, fetching context usage')
|
||||
fetchContextUsage().catch((err) => {
|
||||
logger.warn('[Copilot] Failed to fetch context usage on mount', err)
|
||||
})
|
||||
}
|
||||
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage])
|
||||
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage, standalone])
|
||||
|
||||
/**
|
||||
* Load auto-allowed tools once on mount
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getCodeEditorProps,
|
||||
highlight,
|
||||
languages,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
@@ -74,6 +75,8 @@ interface ConditionInputProps {
|
||||
previewValue?: string | null
|
||||
/** Whether the component is disabled */
|
||||
disabled?: boolean
|
||||
/** Mode: 'condition' for code editor, 'router' for text input */
|
||||
mode?: 'condition' | 'router'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +104,9 @@ export function ConditionInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
mode = 'condition',
|
||||
}: ConditionInputProps) {
|
||||
const isRouterMode = mode === 'router'
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
@@ -161,32 +166,50 @@ export function ConditionInput({
|
||||
const shouldPersistRef = useRef<boolean>(false)
|
||||
|
||||
/**
|
||||
* Creates default if/else conditional blocks with stable IDs.
|
||||
* Creates default blocks with stable IDs.
|
||||
* For conditions: if/else blocks. For router: one route block.
|
||||
*
|
||||
* @returns Array of two default blocks (if and else)
|
||||
* @returns Array of default blocks
|
||||
*/
|
||||
const createDefaultBlocks = (): ConditionalBlock[] => [
|
||||
{
|
||||
id: generateStableId(blockId, 'if'),
|
||||
title: 'if',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
{
|
||||
id: generateStableId(blockId, 'else'),
|
||||
title: 'else',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
]
|
||||
const createDefaultBlocks = (): ConditionalBlock[] => {
|
||||
if (isRouterMode) {
|
||||
return [
|
||||
{
|
||||
id: generateStableId(blockId, 'route1'),
|
||||
title: 'route1',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: generateStableId(blockId, 'if'),
|
||||
title: 'if',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
{
|
||||
id: generateStableId(blockId, 'else'),
|
||||
title: 'else',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Initialize with a loading state instead of default blocks
|
||||
const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([])
|
||||
@@ -270,10 +293,13 @@ export function ConditionInput({
|
||||
const parsedBlocks = safeParseJSON(effectiveValueStr)
|
||||
|
||||
if (parsedBlocks) {
|
||||
const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({
|
||||
...block,
|
||||
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
|
||||
}))
|
||||
// For router mode, keep original titles. For condition mode, assign if/else if/else
|
||||
const blocksWithCorrectTitles = isRouterMode
|
||||
? parsedBlocks
|
||||
: parsedBlocks.map((block, index) => ({
|
||||
...block,
|
||||
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
|
||||
}))
|
||||
|
||||
setConditionalBlocks(blocksWithCorrectTitles)
|
||||
hasInitializedRef.current = true
|
||||
@@ -573,12 +599,17 @@ export function ConditionInput({
|
||||
|
||||
/**
|
||||
* Updates block titles based on their position in the array.
|
||||
* First block is always 'if', last is 'else', middle ones are 'else if'.
|
||||
* For conditions: First block is 'if', last is 'else', middle ones are 'else if'.
|
||||
* For router: Titles are user-editable and not auto-updated.
|
||||
*
|
||||
* @param blocks - Array of conditional blocks
|
||||
* @returns Updated blocks with correct titles
|
||||
*/
|
||||
const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => {
|
||||
if (isRouterMode) {
|
||||
// For router mode, don't change titles - they're user-editable
|
||||
return blocks
|
||||
}
|
||||
return blocks.map((block, index) => ({
|
||||
...block,
|
||||
title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if',
|
||||
@@ -590,13 +621,15 @@ export function ConditionInput({
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
|
||||
if (conditionalBlocks[blockIndex]?.title === 'else') return
|
||||
if (!isRouterMode && conditionalBlocks[blockIndex]?.title === 'else') return
|
||||
|
||||
const newBlockId = generateStableId(blockId, `else-if-${Date.now()}`)
|
||||
const newBlockId = isRouterMode
|
||||
? generateStableId(blockId, `route-${Date.now()}`)
|
||||
: generateStableId(blockId, `else-if-${Date.now()}`)
|
||||
|
||||
const newBlock: ConditionalBlock = {
|
||||
id: newBlockId,
|
||||
title: '',
|
||||
title: isRouterMode ? `route-${Date.now()}` : '',
|
||||
value: '',
|
||||
showTags: false,
|
||||
showEnvVars: false,
|
||||
@@ -710,13 +743,15 @@ export function ConditionInput({
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-hidden bg-transparent px-[10px] py-[5px]',
|
||||
block.title === 'else'
|
||||
? 'rounded-[4px] border-0'
|
||||
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
|
||||
isRouterMode
|
||||
? 'rounded-t-[4px] border-[var(--border-1)] border-b'
|
||||
: block.title === 'else'
|
||||
? 'rounded-[4px] border-0'
|
||||
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{block.title}
|
||||
{isRouterMode ? `Route ${index + 1}` : block.title}
|
||||
</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Tooltip.Root>
|
||||
@@ -724,7 +759,7 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => addBlock(block.id)}
|
||||
disabled={isPreview || disabled || block.title === 'else'}
|
||||
disabled={isPreview || disabled || (!isRouterMode && block.title === 'else')}
|
||||
className='h-auto p-0'
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
@@ -739,7 +774,12 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => moveBlock(block.id, 'up')}
|
||||
disabled={isPreview || index === 0 || disabled || block.title === 'else'}
|
||||
disabled={
|
||||
isPreview ||
|
||||
index === 0 ||
|
||||
disabled ||
|
||||
(!isRouterMode && block.title === 'else')
|
||||
}
|
||||
className='h-auto p-0'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
@@ -758,8 +798,8 @@ export function ConditionInput({
|
||||
isPreview ||
|
||||
disabled ||
|
||||
index === conditionalBlocks.length - 1 ||
|
||||
conditionalBlocks[index + 1]?.title === 'else' ||
|
||||
block.title === 'else'
|
||||
(!isRouterMode && conditionalBlocks[index + 1]?.title === 'else') ||
|
||||
(!isRouterMode && block.title === 'else')
|
||||
}
|
||||
className='h-auto p-0'
|
||||
>
|
||||
@@ -775,18 +815,122 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeBlock(block.id)}
|
||||
disabled={isPreview || conditionalBlocks.length === 1 || disabled}
|
||||
disabled={isPreview || disabled || conditionalBlocks.length === 1}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Delete Block</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete Condition</Tooltip.Content>
|
||||
<Tooltip.Content>
|
||||
{isRouterMode ? 'Delete Route' : 'Delete Condition'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{block.title !== 'else' &&
|
||||
{/* Router mode: show description textarea with tag/env var support */}
|
||||
{isRouterMode && (
|
||||
<div
|
||||
className='relative'
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDrop(block.id, e)}
|
||||
>
|
||||
<Textarea
|
||||
data-router-block-id={block.id}
|
||||
value={block.value}
|
||||
onChange={(e) => {
|
||||
if (!isPreview && !disabled) {
|
||||
const newValue = e.target.value
|
||||
const pos = e.target.selectionStart ?? 0
|
||||
|
||||
const tagTrigger = checkTagTrigger(newValue, pos)
|
||||
const envVarTrigger = checkEnvVarTrigger(newValue, pos)
|
||||
|
||||
shouldPersistRef.current = true
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id
|
||||
? {
|
||||
...b,
|
||||
value: newValue,
|
||||
showTags: tagTrigger.show,
|
||||
showEnvVars: envVarTrigger.show,
|
||||
searchTerm: envVarTrigger.show ? envVarTrigger.searchTerm : '',
|
||||
cursorPosition: pos,
|
||||
}
|
||||
: b
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id ? { ...b, showTags: false, showEnvVars: false } : b
|
||||
)
|
||||
)
|
||||
}, 150)
|
||||
}}
|
||||
placeholder='Describe when this route should be taken...'
|
||||
disabled={disabled || isPreview}
|
||||
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{block.showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={block.showEnvVars}
|
||||
onSelect={(newValue) => handleEnvVarSelectImmediate(block.id, newValue)}
|
||||
searchTerm={block.searchTerm}
|
||||
inputValue={block.value}
|
||||
cursorPosition={block.cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id
|
||||
? {
|
||||
...b,
|
||||
showEnvVars: false,
|
||||
searchTerm: '',
|
||||
}
|
||||
: b
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{block.showTags && (
|
||||
<TagDropdown
|
||||
visible={block.showTags}
|
||||
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={block.activeSourceBlockId}
|
||||
inputValue={block.value}
|
||||
cursorPosition={block.cursorPosition}
|
||||
onClose={() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id
|
||||
? {
|
||||
...b,
|
||||
showTags: false,
|
||||
activeSourceBlockId: null,
|
||||
}
|
||||
: b
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition mode: show code editor */}
|
||||
{!isRouterMode &&
|
||||
block.title !== 'else' &&
|
||||
(() => {
|
||||
const blockLineCount = block.value.split('\n').length
|
||||
const blockGutterWidth = calculateGutterWidth(blockLineCount)
|
||||
|
||||
@@ -46,6 +46,8 @@ interface DropdownProps {
|
||||
) => Promise<Array<{ label: string; id: string }>>
|
||||
/** Field dependencies that trigger option refetch when changed */
|
||||
dependsOn?: SubBlockConfig['dependsOn']
|
||||
/** Enable search input in dropdown */
|
||||
searchable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,7 @@ export function Dropdown({
|
||||
multiSelect = false,
|
||||
fetchOptions,
|
||||
dependsOn,
|
||||
searchable = false,
|
||||
}: DropdownProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
|
||||
string | string[] | null | undefined,
|
||||
@@ -113,7 +116,13 @@ export function Dropdown({
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
const singleValue = multiSelect ? null : (value as string | null | undefined)
|
||||
const multiValues = multiSelect ? (value as string[] | null | undefined) || [] : null
|
||||
const multiValues = multiSelect
|
||||
? Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value as string]
|
||||
: []
|
||||
: null
|
||||
|
||||
const fetchOptionsIfNeeded = useCallback(async () => {
|
||||
if (!fetchOptions || isPreview || disabled) return
|
||||
@@ -363,7 +372,7 @@ export function Dropdown({
|
||||
)
|
||||
}, [multiSelect, multiValues, optionMap])
|
||||
|
||||
const isSearchable = subBlockId === 'operation'
|
||||
const isSearchable = searchable || (subBlockId === 'operation' && comboboxOptions.length > 5)
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
@@ -385,7 +394,7 @@ export function Dropdown({
|
||||
isLoading={isLoadingOptions}
|
||||
error={fetchError}
|
||||
searchable={isSearchable}
|
||||
searchPlaceholder='Search operations...'
|
||||
searchPlaceholder='Search...'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PopoverAnchor,
|
||||
PopoverBackButton,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverFolder,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
@@ -1426,7 +1427,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 +1503,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 +1567,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 +1650,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 +1720,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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { TimePicker } from '@/components/emcn'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
|
||||
interface TimeInputProps {
|
||||
@@ -15,6 +13,10 @@ interface TimeInputProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Time input wrapper for sub-block editor.
|
||||
* Connects the EMCN TimePicker to the sub-block store.
|
||||
*/
|
||||
export function TimeInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
@@ -26,143 +28,20 @@ export function TimeInput({
|
||||
}: TimeInputProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
// Convert 24h time string to display format (12h with AM/PM)
|
||||
const formatDisplayTime = (time: string) => {
|
||||
if (!time) return ''
|
||||
const [hours, minutes] = time.split(':')
|
||||
const hour = Number.parseInt(hours, 10)
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM'
|
||||
const displayHour = hour % 12 || 12
|
||||
return `${displayHour}:${minutes} ${ampm}`
|
||||
}
|
||||
|
||||
// Convert display time to 24h format for storage
|
||||
const formatStorageTime = (hour: number, minute: number, ampm: string) => {
|
||||
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
|
||||
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const [hour, setHour] = React.useState<string>('12')
|
||||
const [minute, setMinute] = React.useState<string>('00')
|
||||
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>('AM')
|
||||
|
||||
// Update the time when any component changes
|
||||
const updateTime = (newHour?: string, newMinute?: string, newAmpm?: 'AM' | 'PM') => {
|
||||
const handleChange = (newValue: string) => {
|
||||
if (isPreview || disabled) return
|
||||
const h = Number.parseInt(newHour ?? hour) || 12
|
||||
const m = Number.parseInt(newMinute ?? minute) || 0
|
||||
const p = newAmpm ?? ampm
|
||||
setStoreValue(formatStorageTime(h, m, p))
|
||||
}
|
||||
|
||||
// Initialize from existing value
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
const [hours, minutes] = value.split(':')
|
||||
const hour24 = Number.parseInt(hours, 10)
|
||||
const _minute = Number.parseInt(minutes, 10)
|
||||
const isAM = hour24 < 12
|
||||
setHour((hour24 % 12 || 12).toString())
|
||||
setMinute(minutes)
|
||||
setAmpm(isAM ? 'AM' : 'PM')
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleBlur = () => {
|
||||
updateTime()
|
||||
setIsOpen(false)
|
||||
setStoreValue(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open)
|
||||
if (!open) {
|
||||
handleBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className='relative w-full cursor-pointer'>
|
||||
<Input
|
||||
readOnly
|
||||
disabled={isPreview || disabled}
|
||||
value={value ? formatDisplayTime(value) : ''}
|
||||
placeholder={placeholder || 'Select time'}
|
||||
autoComplete='off'
|
||||
className={cn('cursor-pointer', !value && 'text-muted-foreground', className)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Input
|
||||
className='w-[4rem]'
|
||||
value={hour}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '')
|
||||
if (val === '') {
|
||||
setHour('')
|
||||
return
|
||||
}
|
||||
const numVal = Number.parseInt(val)
|
||||
if (!Number.isNaN(numVal)) {
|
||||
const newHour = Math.min(12, Math.max(1, numVal)).toString()
|
||||
setHour(newHour)
|
||||
updateTime(newHour)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const numVal = Number.parseInt(hour) || 12
|
||||
setHour(numVal.toString())
|
||||
updateTime(numVal.toString())
|
||||
}}
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<span className='text-[var(--text-primary)]'>:</span>
|
||||
<Input
|
||||
className='w-[4rem]'
|
||||
value={minute}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '')
|
||||
if (val === '') {
|
||||
setMinute('')
|
||||
return
|
||||
}
|
||||
const numVal = Number.parseInt(val)
|
||||
if (!Number.isNaN(numVal)) {
|
||||
const newMinute = Math.min(59, Math.max(0, numVal)).toString().padStart(2, '0')
|
||||
setMinute(newMinute)
|
||||
updateTime(undefined, newMinute)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const numVal = Number.parseInt(minute) || 0
|
||||
setMinute(numVal.toString().padStart(2, '0'))
|
||||
updateTime(undefined, numVal.toString())
|
||||
}}
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-[4rem]'
|
||||
onClick={() => {
|
||||
const newAmpm = ampm === 'AM' ? 'PM' : 'AM'
|
||||
setAmpm(newAmpm)
|
||||
updateTime(undefined, undefined, newAmpm)
|
||||
}}
|
||||
>
|
||||
{ampm}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TimePicker
|
||||
value={value || undefined}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder || 'Select time'}
|
||||
disabled={isPreview || disabled}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -461,6 +461,7 @@ function SubBlockComponent({
|
||||
multiSelect={config.multiSelect}
|
||||
fetchOptions={config.fetchOptions}
|
||||
dependsOn={config.dependsOn}
|
||||
searchable={config.searchable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -604,6 +605,18 @@ function SubBlockComponent({
|
||||
/>
|
||||
)
|
||||
|
||||
case 'router-input':
|
||||
return (
|
||||
<ConditionInput
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
mode='router'
|
||||
/>
|
||||
)
|
||||
|
||||
case 'eval-input':
|
||||
return (
|
||||
<EvalInput
|
||||
|
||||
@@ -841,6 +841,37 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
]
|
||||
}, [type, subBlockState, id])
|
||||
|
||||
/**
|
||||
* Compute per-route rows (id/value) for router_v2 blocks so we can render
|
||||
* one row per route with its own output handle.
|
||||
* Uses same structure as conditions: { id, title, value }
|
||||
*/
|
||||
const routerRows = useMemo(() => {
|
||||
if (type !== 'router_v2') return [] as { id: string; value: string }[]
|
||||
|
||||
const routesValue = subBlockState.routes?.value
|
||||
const raw = typeof routesValue === 'string' ? routesValue : undefined
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const routeItem = item as { id?: string; value?: string }
|
||||
return {
|
||||
id: routeItem?.id ?? `${id}-route-${index}`,
|
||||
value: routeItem?.value ?? '',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse router routes value', { error, blockId: id })
|
||||
}
|
||||
|
||||
return [{ id: `${id}-route-route1`, value: '' }]
|
||||
}, [type, subBlockState, id])
|
||||
|
||||
/**
|
||||
* Compute and publish deterministic layout metrics for workflow blocks.
|
||||
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
|
||||
@@ -857,6 +888,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
let rowsCount = 0
|
||||
if (type === 'condition') {
|
||||
rowsCount = conditionRows.length + defaultHandlesRow
|
||||
} else if (type === 'router_v2') {
|
||||
rowsCount = routerRows.length + defaultHandlesRow
|
||||
} else {
|
||||
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
|
||||
rowsCount = subblockRowCount + defaultHandlesRow
|
||||
@@ -879,6 +912,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
displayTriggerMode,
|
||||
subBlockRows.length,
|
||||
conditionRows.length,
|
||||
routerRows.length,
|
||||
horizontalHandles,
|
||||
],
|
||||
})
|
||||
@@ -1025,7 +1059,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>
|
||||
@@ -1081,24 +1115,32 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
value={getDisplayValue(cond.value)}
|
||||
/>
|
||||
))
|
||||
: subBlockRows.map((row, rowIndex) =>
|
||||
row.map((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
return (
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${rowIndex}`}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
rawValue={rawValue}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
: type === 'router_v2'
|
||||
? routerRows.map((route, index) => (
|
||||
<SubBlockRow
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
value={getDisplayValue(route.value)}
|
||||
/>
|
||||
))
|
||||
: subBlockRows.map((row, rowIndex) =>
|
||||
row.map((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
return (
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${rowIndex}`}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
rawValue={rawValue}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
|
||||
</div>
|
||||
)}
|
||||
@@ -1153,7 +1195,57 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
</>
|
||||
)}
|
||||
|
||||
{type !== 'condition' && type !== 'response' && (
|
||||
{type === 'router_v2' && (
|
||||
<>
|
||||
{routerRows.map((route, routeIndex) => {
|
||||
const topOffset =
|
||||
HANDLE_POSITIONS.CONDITION_START_Y +
|
||||
routeIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
|
||||
return (
|
||||
<Handle
|
||||
key={`handle-${route.id}`}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={`router-${route.id}`}
|
||||
className={getHandleClasses('right')}
|
||||
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
data-nodeid={id}
|
||||
data-handleid={`router-${route.id}`}
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
|
||||
<>
|
||||
<Handle
|
||||
type='source'
|
||||
|
||||
@@ -24,11 +24,9 @@ export function useFloatBoundarySync({
|
||||
const positionRef = useRef(position)
|
||||
const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 })
|
||||
|
||||
// Keep position ref up to date
|
||||
positionRef.current = position
|
||||
|
||||
const checkAndUpdatePosition = useCallback(() => {
|
||||
// Get current layout dimensions
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
@@ -39,20 +37,17 @@ export function useFloatBoundarySync({
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
// Check if dimensions actually changed
|
||||
const prev = previousDimensionsRef.current
|
||||
if (
|
||||
prev.sidebarWidth === sidebarWidth &&
|
||||
prev.panelWidth === panelWidth &&
|
||||
prev.terminalHeight === terminalHeight
|
||||
) {
|
||||
return // No change, skip update
|
||||
return
|
||||
}
|
||||
|
||||
// Update previous dimensions
|
||||
previousDimensionsRef.current = { sidebarWidth, panelWidth, terminalHeight }
|
||||
|
||||
// Calculate bounds
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - panelWidth - width
|
||||
const minY = 0
|
||||
@@ -60,9 +55,7 @@ export function useFloatBoundarySync({
|
||||
|
||||
const currentPos = positionRef.current
|
||||
|
||||
// Check if current position is out of bounds
|
||||
if (currentPos.x < minX || currentPos.x > maxX || currentPos.y < minY || currentPos.y > maxY) {
|
||||
// Constrain to new bounds
|
||||
const newPosition = {
|
||||
x: Math.max(minX, Math.min(maxX, currentPos.x)),
|
||||
y: Math.max(minY, Math.min(maxY, currentPos.y)),
|
||||
@@ -75,30 +68,24 @@ export function useFloatBoundarySync({
|
||||
if (!isOpen) return
|
||||
|
||||
const handleResize = () => {
|
||||
// Cancel any pending animation frame
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
}
|
||||
|
||||
// Schedule update on next animation frame for smooth 60fps updates
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
checkAndUpdatePosition()
|
||||
rafIdRef.current = null
|
||||
})
|
||||
}
|
||||
|
||||
// Listen for window resize
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// Create MutationObserver to watch for CSS variable changes
|
||||
// This fires immediately when sidebar/panel/terminal resize
|
||||
const observer = new MutationObserver(handleResize)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
})
|
||||
|
||||
// Initial check
|
||||
checkAndUpdatePosition()
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -22,7 +22,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
|
||||
*/
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only left click
|
||||
if (e.button !== 0) return
|
||||
|
||||
e.preventDefault()
|
||||
@@ -32,7 +31,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY }
|
||||
initialPositionRef.current = { ...position }
|
||||
|
||||
// Add dragging cursor to body
|
||||
document.body.style.cursor = 'grabbing'
|
||||
document.body.style.userSelect = 'none'
|
||||
},
|
||||
@@ -54,7 +52,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
|
||||
y: initialPositionRef.current.y + deltaY,
|
||||
}
|
||||
|
||||
// Constrain to bounds
|
||||
const constrainedPosition = constrainChatPosition(newPosition, width, height)
|
||||
onPositionChange(constrainedPosition)
|
||||
},
|
||||
@@ -69,7 +66,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
|
||||
|
||||
isDraggingRef.current = false
|
||||
|
||||
// Remove dragging cursor
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}, [])
|
||||
|
||||
@@ -84,13 +84,11 @@ export function useFloatResize({
|
||||
const isNearLeft = x <= EDGE_THRESHOLD
|
||||
const isNearRight = x >= rect.width - EDGE_THRESHOLD
|
||||
|
||||
// Check corners first (they take priority over edges)
|
||||
if (isNearTop && isNearLeft) return 'top-left'
|
||||
if (isNearTop && isNearRight) return 'top-right'
|
||||
if (isNearBottom && isNearLeft) return 'bottom-left'
|
||||
if (isNearBottom && isNearRight) return 'bottom-right'
|
||||
|
||||
// Check edges
|
||||
if (isNearTop) return 'top'
|
||||
if (isNearBottom) return 'bottom'
|
||||
if (isNearLeft) return 'left'
|
||||
@@ -155,7 +153,6 @@ export function useFloatResize({
|
||||
*/
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only left click
|
||||
if (e.button !== 0) return
|
||||
|
||||
const chatElement = e.currentTarget as HTMLElement
|
||||
@@ -176,7 +173,6 @@ export function useFloatResize({
|
||||
height,
|
||||
}
|
||||
|
||||
// Set cursor on body
|
||||
document.body.style.cursor = getCursorForDirection(direction)
|
||||
document.body.style.userSelect = 'none'
|
||||
},
|
||||
@@ -195,7 +191,6 @@ export function useFloatResize({
|
||||
const initial = initialStateRef.current
|
||||
const direction = activeDirectionRef.current
|
||||
|
||||
// Get layout bounds
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
@@ -206,18 +201,13 @@ export function useFloatResize({
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
// Clamp vertical drag when resizing from the top so the chat does not grow downward
|
||||
// after its top edge hits the top of the viewport.
|
||||
if (direction === 'top' || direction === 'top-left' || direction === 'top-right') {
|
||||
// newY = initial.y + deltaY should never be less than 0
|
||||
const maxUpwardDelta = initial.y
|
||||
if (deltaY < -maxUpwardDelta) {
|
||||
deltaY = -maxUpwardDelta
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp vertical drag when resizing from the bottom so the chat does not grow upward
|
||||
// after its bottom edge hits the top of the terminal.
|
||||
if (direction === 'bottom' || direction === 'bottom-left' || direction === 'bottom-right') {
|
||||
const maxBottom = window.innerHeight - terminalHeight
|
||||
const initialBottom = initial.y + initial.height
|
||||
@@ -228,8 +218,6 @@ export function useFloatResize({
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp horizontal drag when resizing from the left so the chat does not grow to the right
|
||||
// after its left edge hits the sidebar.
|
||||
if (direction === 'left' || direction === 'top-left' || direction === 'bottom-left') {
|
||||
const minLeft = sidebarWidth
|
||||
const minDeltaX = minLeft - initial.x
|
||||
@@ -239,8 +227,6 @@ export function useFloatResize({
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp horizontal drag when resizing from the right so the chat does not grow to the left
|
||||
// after its right edge hits the panel.
|
||||
if (direction === 'right' || direction === 'top-right' || direction === 'bottom-right') {
|
||||
const maxRight = window.innerWidth - panelWidth
|
||||
const initialRight = initial.x + initial.width
|
||||
@@ -256,9 +242,7 @@ export function useFloatResize({
|
||||
let newWidth = initial.width
|
||||
let newHeight = initial.height
|
||||
|
||||
// Calculate new dimensions based on resize direction
|
||||
switch (direction) {
|
||||
// Corners
|
||||
case 'top-left':
|
||||
newWidth = initial.width - deltaX
|
||||
newHeight = initial.height - deltaY
|
||||
@@ -280,7 +264,6 @@ export function useFloatResize({
|
||||
newHeight = initial.height + deltaY
|
||||
break
|
||||
|
||||
// Edges
|
||||
case 'top':
|
||||
newHeight = initial.height - deltaY
|
||||
newY = initial.y + deltaY
|
||||
@@ -297,8 +280,6 @@ export function useFloatResize({
|
||||
break
|
||||
}
|
||||
|
||||
// Constrain dimensions to min/max. If explicit constraints are not provided,
|
||||
// fall back to the chat defaults for backward compatibility.
|
||||
const effectiveMinWidth = typeof minWidth === 'number' ? minWidth : MIN_CHAT_WIDTH
|
||||
const effectiveMaxWidth = typeof maxWidth === 'number' ? maxWidth : MAX_CHAT_WIDTH
|
||||
const effectiveMinHeight = typeof minHeight === 'number' ? minHeight : MIN_CHAT_HEIGHT
|
||||
@@ -310,7 +291,6 @@ export function useFloatResize({
|
||||
Math.min(effectiveMaxHeight, newHeight)
|
||||
)
|
||||
|
||||
// Adjust position if dimensions were constrained on left/top edges
|
||||
if (direction === 'top-left' || direction === 'bottom-left' || direction === 'left') {
|
||||
if (constrainedWidth !== newWidth) {
|
||||
newX = initial.x + initial.width - constrainedWidth
|
||||
@@ -322,7 +302,6 @@ export function useFloatResize({
|
||||
}
|
||||
}
|
||||
|
||||
// Constrain position to bounds
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - panelWidth - constrainedWidth
|
||||
const minY = 0
|
||||
@@ -331,7 +310,6 @@ export function useFloatResize({
|
||||
const finalX = Math.max(minX, Math.min(maxX, newX))
|
||||
const finalY = Math.max(minY, Math.min(maxY, newY))
|
||||
|
||||
// Update state
|
||||
onDimensionsChange({
|
||||
width: constrainedWidth,
|
||||
height: constrainedHeight,
|
||||
@@ -353,7 +331,6 @@ export function useFloatResize({
|
||||
isResizingRef.current = false
|
||||
activeDirectionRef.current = null
|
||||
|
||||
// Remove cursor from body
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
setCursor('')
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -80,6 +80,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 +93,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>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface ContextMenuProps {
|
||||
/**
|
||||
@@ -142,7 +148,13 @@ export function ContextMenu({
|
||||
disableCreateFolder = false,
|
||||
}: ContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -153,6 +165,7 @@ export function ContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Navigation actions */}
|
||||
{showOpenInNewTab && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
@@ -163,6 +176,9 @@ export function ContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
|
||||
|
||||
{/* Edit and create actions */}
|
||||
{showRename && onRename && (
|
||||
<PopoverItem
|
||||
disabled={disableRename}
|
||||
@@ -196,6 +212,9 @@ export function ContextMenu({
|
||||
Create folder
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Copy and export actions */}
|
||||
{(showDuplicate || showExport) && <PopoverDivider />}
|
||||
{showDuplicate && onDuplicate && (
|
||||
<PopoverItem
|
||||
disabled={disableDuplicate}
|
||||
@@ -218,6 +237,9 @@ export function ContextMenu({
|
||||
Export
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
|
||||
@@ -180,10 +180,7 @@ export const PermissionsTable = ({
|
||||
{resendingInvitationIds &&
|
||||
user.invitationId &&
|
||||
resendingInvitationIds[user.invitationId] ? (
|
||||
<>
|
||||
<Loader2 className='h-[12px] w-[12px] animate-spin' />
|
||||
<span>Sending...</span>
|
||||
</>
|
||||
<span>Sending...</span>
|
||||
) : resentInvitationIds &&
|
||||
user.invitationId &&
|
||||
resentInvitationIds[user.invitationId] ? (
|
||||
|
||||
@@ -341,7 +341,7 @@ export function WorkspaceHeader({
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>
|
||||
{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}
|
||||
</p>
|
||||
@@ -364,7 +364,7 @@ export function WorkspaceHeader({
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>
|
||||
{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
|
||||
</p>
|
||||
|
||||
@@ -529,7 +529,7 @@ export function Sidebar() {
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -544,7 +544,7 @@ export function Sidebar() {
|
||||
<FolderPlus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -559,7 +559,7 @@ export function Sidebar() {
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<Tooltip.Content>
|
||||
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
getSubBlockValue,
|
||||
} from '@/lib/workflows/schedules/utils'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
@@ -285,14 +286,25 @@ async function runWorkflowExecution({
|
||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||
executionId,
|
||||
})
|
||||
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||
} else {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId: payload.workflowId,
|
||||
executionId,
|
||||
pausePoints: executionResult.pausePoints || [],
|
||||
snapshotSeed: executionResult.snapshotSeed,
|
||||
executorUserId: executionResult.metadata?.userId,
|
||||
})
|
||||
try {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId: payload.workflowId,
|
||||
executionId,
|
||||
pausePoints: executionResult.pausePoints || [],
|
||||
snapshotSeed: executionResult.snapshotSeed,
|
||||
executorUserId: executionResult.metadata?.userId,
|
||||
})
|
||||
} catch (pauseError) {
|
||||
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||
executionId,
|
||||
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||
})
|
||||
await loggingSession.markAsFailed(
|
||||
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await PauseResumeManager.processQueuedResumes(executionId)
|
||||
@@ -360,8 +372,7 @@ function calculateNextRunTime(
|
||||
return nextDate
|
||||
}
|
||||
|
||||
const lastRanAt = schedule.lastRanAt ? new Date(schedule.lastRanAt) : null
|
||||
return calculateNextTime(scheduleType, scheduleValues, lastRanAt)
|
||||
return calculateNextTime(scheduleType, scheduleValues)
|
||||
}
|
||||
|
||||
export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
@@ -268,14 +269,25 @@ async function executeWebhookJobInternal(
|
||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||
executionId,
|
||||
})
|
||||
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||
} else {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId: payload.workflowId,
|
||||
executionId,
|
||||
pausePoints: executionResult.pausePoints || [],
|
||||
snapshotSeed: executionResult.snapshotSeed,
|
||||
executorUserId: executionResult.metadata?.userId,
|
||||
})
|
||||
try {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId: payload.workflowId,
|
||||
executionId,
|
||||
pausePoints: executionResult.pausePoints || [],
|
||||
snapshotSeed: executionResult.snapshotSeed,
|
||||
executorUserId: executionResult.metadata?.userId,
|
||||
})
|
||||
} catch (pauseError) {
|
||||
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||
executionId,
|
||||
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||
})
|
||||
await loggingSession.markAsFailed(
|
||||
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await PauseResumeManager.processQueuedResumes(executionId)
|
||||
@@ -509,14 +521,25 @@ async function executeWebhookJobInternal(
|
||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||
executionId,
|
||||
})
|
||||
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||
} else {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId: payload.workflowId,
|
||||
executionId,
|
||||
pausePoints: executionResult.pausePoints || [],
|
||||
snapshotSeed: executionResult.snapshotSeed,
|
||||
executorUserId: executionResult.metadata?.userId,
|
||||
})
|
||||
try {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId: payload.workflowId,
|
||||
executionId,
|
||||
pausePoints: executionResult.pausePoints || [],
|
||||
snapshotSeed: executionResult.snapshotSeed,
|
||||
executorUserId: executionResult.metadata?.userId,
|
||||
})
|
||||
} catch (pauseError) {
|
||||
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||
executionId,
|
||||
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||
})
|
||||
await loggingSession.markAsFailed(
|
||||
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await PauseResumeManager.processQueuedResumes(executionId)
|
||||
|
||||
@@ -7,7 +7,8 @@ import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
const logger = createLogger('TriggerWorkflowExecution')
|
||||
@@ -112,14 +113,25 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||
executionId,
|
||||
})
|
||||
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||
} else {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId,
|
||||
executionId,
|
||||
pausePoints: result.pausePoints || [],
|
||||
snapshotSeed: result.snapshotSeed,
|
||||
executorUserId: result.metadata?.userId,
|
||||
})
|
||||
try {
|
||||
await PauseResumeManager.persistPauseResult({
|
||||
workflowId,
|
||||
executionId,
|
||||
pausePoints: result.pausePoints || [],
|
||||
snapshotSeed: result.snapshotSeed,
|
||||
executorUserId: result.metadata?.userId,
|
||||
})
|
||||
} catch (pauseError) {
|
||||
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||
executionId,
|
||||
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||
})
|
||||
await loggingSession.markAsFailed(
|
||||
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await PauseResumeManager.processQueuedResumes(executionId)
|
||||
|
||||
53
apps/sim/blocks/blocks/imap.ts
Normal file
53
apps/sim/blocks/blocks/imap.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { MailServerIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
export const ImapBlock: BlockConfig = {
|
||||
type: 'imap',
|
||||
name: 'IMAP Email',
|
||||
description: 'Trigger workflows when new emails arrive via IMAP (works with any email provider)',
|
||||
longDescription:
|
||||
'Connect to any email server via IMAP protocol to trigger workflows when new emails are received. Supports Gmail, Outlook, Yahoo, and any other IMAP-compatible email provider.',
|
||||
category: 'triggers',
|
||||
bgColor: '#6366F1',
|
||||
icon: MailServerIcon,
|
||||
triggerAllowed: true,
|
||||
hideFromToolbar: false,
|
||||
subBlocks: [...getTrigger('imap_poller').subBlocks],
|
||||
tools: {
|
||||
access: [],
|
||||
config: {
|
||||
tool: () => '',
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
host: { type: 'string', description: 'IMAP server hostname' },
|
||||
port: { type: 'string', description: 'IMAP server port' },
|
||||
secure: { type: 'boolean', description: 'Use SSL/TLS encryption' },
|
||||
rejectUnauthorized: { type: 'boolean', description: 'Verify TLS certificate' },
|
||||
username: { type: 'string', description: 'Email username' },
|
||||
password: { type: 'string', description: 'Email password' },
|
||||
mailbox: { type: 'string', description: 'Mailbox to monitor' },
|
||||
searchCriteria: { type: 'string', description: 'IMAP search criteria' },
|
||||
markAsRead: { type: 'boolean', description: 'Mark emails as read after processing' },
|
||||
includeAttachments: { type: 'boolean', description: 'Include email attachments' },
|
||||
},
|
||||
outputs: {
|
||||
messageId: { type: 'string', description: 'RFC Message-ID header' },
|
||||
subject: { type: 'string', description: 'Email subject line' },
|
||||
from: { type: 'string', description: 'Sender email address' },
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
cc: { type: 'string', description: 'CC recipients' },
|
||||
date: { type: 'string', description: 'Email date in ISO format' },
|
||||
bodyText: { type: 'string', description: 'Plain text email body' },
|
||||
bodyHtml: { type: 'string', description: 'HTML email body' },
|
||||
mailbox: { type: 'string', description: 'Mailbox/folder where email was received' },
|
||||
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
|
||||
attachments: { type: 'json', description: 'Array of email attachments' },
|
||||
timestamp: { type: 'string', description: 'Event timestamp' },
|
||||
},
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: ['imap_poller'],
|
||||
},
|
||||
}
|
||||
@@ -51,6 +51,9 @@ interface TargetBlock {
|
||||
currentState?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the system prompt for the legacy router (block-based).
|
||||
*/
|
||||
export const generateRouterPrompt = (prompt: string, targetBlocks?: TargetBlock[]): string => {
|
||||
const basePrompt = `You are an intelligent routing agent responsible for directing workflow requests to the most appropriate block. Your task is to analyze the input and determine the single most suitable destination based on the request.
|
||||
|
||||
@@ -107,9 +110,88 @@ Example: "2acd9007-27e8-4510-a487-73d3b825e7c1"
|
||||
Remember: Your response must be ONLY the block ID - no additional text, formatting, or explanation.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the system prompt for the port-based router (v2).
|
||||
* Instead of selecting a block by ID, it selects a route by evaluating all route descriptions.
|
||||
*/
|
||||
export const generateRouterV2Prompt = (
|
||||
context: string,
|
||||
routes: Array<{ id: string; title: string; value: string }>
|
||||
): string => {
|
||||
const routesInfo = routes
|
||||
.map(
|
||||
(route, index) => `
|
||||
Route ${index + 1}:
|
||||
ID: ${route.id}
|
||||
Description: ${route.value || 'No description provided'}
|
||||
---`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
|
||||
|
||||
Available Routes:
|
||||
${routesInfo}
|
||||
|
||||
Context to analyze:
|
||||
${context}
|
||||
|
||||
Instructions:
|
||||
1. Carefully analyze the context against each route's description
|
||||
2. Select the route that best matches the context's intent and requirements
|
||||
3. Consider the semantic meaning, not just keyword matching
|
||||
4. If multiple routes could match, choose the most specific one
|
||||
|
||||
Response Format:
|
||||
Return ONLY the route ID as a single string, no punctuation, no explanation.
|
||||
Example: "route-abc123"
|
||||
|
||||
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get model options for both router versions.
|
||||
*/
|
||||
const getModelOptions = () => {
|
||||
const providersState = useProvidersStore.getState()
|
||||
const baseModels = providersState.providers.base.models
|
||||
const ollamaModels = providersState.providers.ollama.models
|
||||
const vllmModels = providersState.providers.vllm.models
|
||||
const openrouterModels = providersState.providers.openrouter.models
|
||||
const allModels = Array.from(
|
||||
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
|
||||
)
|
||||
|
||||
return allModels.map((model) => {
|
||||
const icon = getProviderIcon(model)
|
||||
return { label: model, id: model, ...(icon && { icon }) }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get API key condition for both router versions.
|
||||
*/
|
||||
const getApiKeyCondition = () => {
|
||||
return isHosted
|
||||
? {
|
||||
field: 'model',
|
||||
value: [...getHostedModels(), ...providers.vertex.models],
|
||||
not: true,
|
||||
}
|
||||
: () => ({
|
||||
field: 'model',
|
||||
value: [...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models],
|
||||
not: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Router Block (block-based routing).
|
||||
* Hidden from toolbar but still supported for existing workflows.
|
||||
*/
|
||||
export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
type: 'router',
|
||||
name: 'Router',
|
||||
name: 'Router (Legacy)',
|
||||
description: 'Route workflow',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
@@ -121,6 +203,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
category: 'blocks',
|
||||
bgColor: '#28C43F',
|
||||
icon: ConnectIcon,
|
||||
hideFromToolbar: true, // Hide legacy version from toolbar
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'prompt',
|
||||
@@ -136,21 +219,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
placeholder: 'Type or select a model...',
|
||||
required: true,
|
||||
defaultValue: 'claude-sonnet-4-5',
|
||||
options: () => {
|
||||
const providersState = useProvidersStore.getState()
|
||||
const baseModels = providersState.providers.base.models
|
||||
const ollamaModels = providersState.providers.ollama.models
|
||||
const vllmModels = providersState.providers.vllm.models
|
||||
const openrouterModels = providersState.providers.openrouter.models
|
||||
const allModels = Array.from(
|
||||
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
|
||||
)
|
||||
|
||||
return allModels.map((model) => {
|
||||
const icon = getProviderIcon(model)
|
||||
return { label: model, id: model, ...(icon && { icon }) }
|
||||
})
|
||||
},
|
||||
options: getModelOptions,
|
||||
},
|
||||
{
|
||||
id: 'vertexCredential',
|
||||
@@ -173,22 +242,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
// Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
|
||||
condition: isHosted
|
||||
? {
|
||||
field: 'model',
|
||||
value: [...getHostedModels(), ...providers.vertex.models],
|
||||
not: true, // Show for all models EXCEPT those listed
|
||||
}
|
||||
: () => ({
|
||||
field: 'model',
|
||||
value: [
|
||||
...getCurrentOllamaModels(),
|
||||
...getCurrentVLLMModels(),
|
||||
...providers.vertex.models,
|
||||
],
|
||||
not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
|
||||
}),
|
||||
condition: getApiKeyCondition(),
|
||||
},
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
@@ -303,3 +357,185 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Router V2 Block (port-based routing).
|
||||
* Uses route definitions with descriptions instead of downstream block names.
|
||||
*/
|
||||
interface RouterV2Response extends ToolResponse {
|
||||
output: {
|
||||
context: string
|
||||
model: string
|
||||
tokens?: {
|
||||
prompt?: number
|
||||
completion?: number
|
||||
total?: number
|
||||
}
|
||||
cost?: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
selectedRoute: string
|
||||
selectedPath: {
|
||||
blockId: string
|
||||
blockType: string
|
||||
blockTitle: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const RouterV2Block: BlockConfig<RouterV2Response> = {
|
||||
type: 'router_v2',
|
||||
name: 'Router',
|
||||
description: 'Route workflow based on context',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Intelligently route workflow execution to different paths based on context analysis. Define multiple routes with descriptions, and an LLM will determine which route to take based on the provided context.',
|
||||
bestPractices: `
|
||||
- Write clear, specific descriptions for each route
|
||||
- The context field should contain all relevant information for routing decisions
|
||||
- Route descriptions should be mutually exclusive when possible
|
||||
- Use descriptive route names to make the workflow readable
|
||||
`,
|
||||
category: 'blocks',
|
||||
bgColor: '#28C43F',
|
||||
icon: ConnectIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'context',
|
||||
title: 'Context',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter the context to analyze for routing...',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'routes',
|
||||
type: 'router-input',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'combobox',
|
||||
placeholder: 'Type or select a model...',
|
||||
required: true,
|
||||
defaultValue: 'claude-sonnet-4-5',
|
||||
options: getModelOptions,
|
||||
},
|
||||
{
|
||||
id: 'vertexCredential',
|
||||
title: 'Google Cloud Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'vertex-ai',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
placeholder: 'Select Google Cloud account',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: getApiKeyCondition(),
|
||||
},
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
title: 'Azure OpenAI Endpoint',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'https://your-resource.openai.azure.com',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'azureApiVersion',
|
||||
title: 'Azure API Version',
|
||||
type: 'short-input',
|
||||
placeholder: '2024-07-01-preview',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexProject',
|
||||
title: 'Vertex AI Project',
|
||||
type: 'short-input',
|
||||
placeholder: 'your-gcp-project-id',
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexLocation',
|
||||
title: 'Vertex AI Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-central1',
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'openai_chat',
|
||||
'anthropic_chat',
|
||||
'google_chat',
|
||||
'xai_chat',
|
||||
'deepseek_chat',
|
||||
'deepseek_reasoner',
|
||||
],
|
||||
config: {
|
||||
tool: (params: Record<string, any>) => {
|
||||
const model = params.model || 'gpt-4o'
|
||||
if (!model) {
|
||||
throw new Error('No model selected')
|
||||
}
|
||||
const tool = getAllModelProviders()[model as ProviderId]
|
||||
if (!tool) {
|
||||
throw new Error(`Invalid model selected: ${model}`)
|
||||
}
|
||||
return tool
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
context: { type: 'string', description: 'Context for routing decision' },
|
||||
routes: { type: 'json', description: 'Route definitions with descriptions' },
|
||||
model: { type: 'string', description: 'AI model to use' },
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
vertexCredential: {
|
||||
type: 'string',
|
||||
description: 'Google Cloud OAuth credential ID for Vertex AI',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
context: { type: 'string', description: 'Context used for routing' },
|
||||
model: { type: 'string', description: 'Model used' },
|
||||
tokens: { type: 'json', description: 'Token usage' },
|
||||
cost: { type: 'json', description: 'Cost information' },
|
||||
selectedRoute: { type: 'string', description: 'Selected route ID' },
|
||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -128,24 +128,48 @@ export const ScheduleBlock: BlockConfig = {
|
||||
id: 'timezone',
|
||||
type: 'dropdown',
|
||||
title: 'Timezone',
|
||||
searchable: true,
|
||||
options: [
|
||||
// UTC
|
||||
{ label: 'UTC', id: 'UTC' },
|
||||
{ label: 'US Eastern (UTC-5)', id: 'America/New_York' },
|
||||
{ label: 'US Central (UTC-6)', id: 'America/Chicago' },
|
||||
{ label: 'US Mountain (UTC-7)', id: 'America/Denver' },
|
||||
// Americas
|
||||
{ label: 'US Pacific (UTC-8)', id: 'America/Los_Angeles' },
|
||||
{ label: 'US Mountain (UTC-7)', id: 'America/Denver' },
|
||||
{ label: 'US Central (UTC-6)', id: 'America/Chicago' },
|
||||
{ label: 'US Eastern (UTC-5)', id: 'America/New_York' },
|
||||
{ label: 'US Alaska (UTC-9)', id: 'America/Anchorage' },
|
||||
{ label: 'US Hawaii (UTC-10)', id: 'Pacific/Honolulu' },
|
||||
{ label: 'Canada Toronto (UTC-5)', id: 'America/Toronto' },
|
||||
{ label: 'Canada Vancouver (UTC-8)', id: 'America/Vancouver' },
|
||||
{ label: 'Mexico City (UTC-6)', id: 'America/Mexico_City' },
|
||||
{ label: 'São Paulo (UTC-3)', id: 'America/Sao_Paulo' },
|
||||
{ label: 'Buenos Aires (UTC-3)', id: 'America/Argentina/Buenos_Aires' },
|
||||
// Europe
|
||||
{ label: 'London (UTC+0)', id: 'Europe/London' },
|
||||
{ label: 'Paris (UTC+1)', id: 'Europe/Paris' },
|
||||
{ label: 'Berlin (UTC+1)', id: 'Europe/Berlin' },
|
||||
{ label: 'Amsterdam (UTC+1)', id: 'Europe/Amsterdam' },
|
||||
{ label: 'Madrid (UTC+1)', id: 'Europe/Madrid' },
|
||||
{ label: 'Rome (UTC+1)', id: 'Europe/Rome' },
|
||||
{ label: 'Moscow (UTC+3)', id: 'Europe/Moscow' },
|
||||
// Middle East / Africa
|
||||
{ label: 'Dubai (UTC+4)', id: 'Asia/Dubai' },
|
||||
{ label: 'Tel Aviv (UTC+2)', id: 'Asia/Tel_Aviv' },
|
||||
{ label: 'Cairo (UTC+2)', id: 'Africa/Cairo' },
|
||||
{ label: 'Johannesburg (UTC+2)', id: 'Africa/Johannesburg' },
|
||||
// Asia
|
||||
{ label: 'India (UTC+5:30)', id: 'Asia/Kolkata' },
|
||||
{ label: 'Bangkok (UTC+7)', id: 'Asia/Bangkok' },
|
||||
{ label: 'Jakarta (UTC+7)', id: 'Asia/Jakarta' },
|
||||
{ label: 'Singapore (UTC+8)', id: 'Asia/Singapore' },
|
||||
{ label: 'China (UTC+8)', id: 'Asia/Shanghai' },
|
||||
{ label: 'Hong Kong (UTC+8)', id: 'Asia/Hong_Kong' },
|
||||
{ label: 'Seoul (UTC+9)', id: 'Asia/Seoul' },
|
||||
{ label: 'Tokyo (UTC+9)', id: 'Asia/Tokyo' },
|
||||
// Australia / Pacific
|
||||
{ label: 'Perth (UTC+8)', id: 'Australia/Perth' },
|
||||
{ label: 'Sydney (UTC+10)', id: 'Australia/Sydney' },
|
||||
{ label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' },
|
||||
{ label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' },
|
||||
],
|
||||
value: () => 'UTC',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user