mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f415e5edc4 | ||
|
|
13981549d1 | ||
|
|
554dcdf062 | ||
|
|
6b28742b68 | ||
|
|
e5c95093f6 | ||
|
|
b87af80bff | ||
|
|
c2180bf8a0 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
76
apps/docs/content/docs/de/enterprise/index.mdx
Normal file
76
apps/docs/content/docs/de/enterprise/index.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Enterprise
|
||||
description: Enterprise-Funktionen für Organisationen mit erweiterten
|
||||
Sicherheits- und Compliance-Anforderungen
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen.
|
||||
|
||||
---
|
||||
|
||||
## Bring Your Own Key (BYOK)
|
||||
|
||||
Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim Studio.
|
||||
|
||||
### Unterstützte Anbieter
|
||||
|
||||
| Anbieter | Verwendung |
|
||||
|----------|-------|
|
||||
| OpenAI | Knowledge Base-Embeddings, Agent-Block |
|
||||
| Anthropic | Agent-Block |
|
||||
| Google | Agent-Block |
|
||||
| Mistral | Knowledge Base OCR |
|
||||
|
||||
### Einrichtung
|
||||
|
||||
1. Navigieren Sie zu **Einstellungen** → **BYOK** in Ihrem Workspace
|
||||
2. Klicken Sie auf **Schlüssel hinzufügen** für Ihren Anbieter
|
||||
3. Geben Sie Ihren API-Schlüssel ein und speichern Sie
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK-Schlüssel werden verschlüsselt gespeichert. Nur Organisationsadministratoren und -inhaber können Schlüssel verwalten.
|
||||
</Callout>
|
||||
|
||||
Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim Studio. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück.
|
||||
|
||||
---
|
||||
|
||||
## Single Sign-On (SSO)
|
||||
|
||||
Enterprise-Authentifizierung mit SAML 2.0- und OIDC-Unterstützung für zentralisiertes Identitätsmanagement.
|
||||
|
||||
### Unterstützte Anbieter
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- Jeder SAML 2.0- oder OIDC-Anbieter
|
||||
|
||||
### Einrichtung
|
||||
|
||||
1. Navigieren Sie zu **Einstellungen** → **SSO** in Ihrem Workspace
|
||||
2. Wählen Sie Ihren Identitätsanbieter
|
||||
3. Konfigurieren Sie die Verbindung mithilfe der Metadaten Ihres IdP
|
||||
4. Aktivieren Sie SSO für Ihre Organisation
|
||||
|
||||
<Callout type="info">
|
||||
Sobald SSO aktiviert ist, authentifizieren sich Teammitglieder über Ihren Identitätsanbieter anstelle von E-Mail/Passwort.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted
|
||||
|
||||
Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgebungsvariablen aktiviert werden:
|
||||
|
||||
| Variable | Beschreibung |
|
||||
|----------|-------------|
|
||||
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC |
|
||||
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
|
||||
</Callout>
|
||||
75
apps/docs/content/docs/en/enterprise/index.mdx
Normal file
75
apps/docs/content/docs/en/enterprise/index.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Enterprise
|
||||
description: Enterprise features for organizations with advanced security and compliance requirements
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
|
||||
|
||||
---
|
||||
|
||||
## Bring Your Own Key (BYOK)
|
||||
|
||||
Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
| Provider | Usage |
|
||||
|----------|-------|
|
||||
| OpenAI | Knowledge Base embeddings, Agent block |
|
||||
| Anthropic | Agent block |
|
||||
| Google | Agent block |
|
||||
| Mistral | Knowledge Base OCR |
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **BYOK** in your workspace
|
||||
2. Click **Add Key** for your provider
|
||||
3. Enter your API key and save
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK keys are encrypted at rest. Only organization admins and owners can manage keys.
|
||||
</Callout>
|
||||
|
||||
When configured, workflows use your key instead of Sim Studio's hosted keys. If removed, workflows automatically fall back to hosted keys.
|
||||
|
||||
---
|
||||
|
||||
## Single Sign-On (SSO)
|
||||
|
||||
Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- Any SAML 2.0 or OIDC provider
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **SSO** in your workspace
|
||||
2. Choose your identity provider
|
||||
3. Configure the connection using your IdP's metadata
|
||||
4. Enable SSO for your organization
|
||||
|
||||
<Callout type="info">
|
||||
Once SSO is enabled, team members authenticate through your identity provider instead of email/password.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted
|
||||
|
||||
For self-hosted deployments, enterprise features can be enabled via environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
|
||||
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
|
||||
</Callout>
|
||||
@@ -15,6 +15,7 @@
|
||||
"permissions",
|
||||
"sdks",
|
||||
"self-hosting",
|
||||
"./enterprise/index",
|
||||
"./keyboard-shortcuts/index"
|
||||
],
|
||||
"defaultOpen": false
|
||||
|
||||
76
apps/docs/content/docs/es/enterprise/index.mdx
Normal file
76
apps/docs/content/docs/es/enterprise/index.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Enterprise
|
||||
description: Funciones enterprise para organizaciones con requisitos avanzados
|
||||
de seguridad y cumplimiento
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión.
|
||||
|
||||
---
|
||||
|
||||
## Bring Your Own Key (BYOK)
|
||||
|
||||
Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim Studio.
|
||||
|
||||
### Proveedores compatibles
|
||||
|
||||
| Proveedor | Uso |
|
||||
|----------|-------|
|
||||
| OpenAI | Embeddings de base de conocimiento, bloque Agent |
|
||||
| Anthropic | Bloque Agent |
|
||||
| Google | Bloque Agent |
|
||||
| Mistral | OCR de base de conocimiento |
|
||||
|
||||
### Configuración
|
||||
|
||||
1. Navega a **Configuración** → **BYOK** en tu espacio de trabajo
|
||||
2. Haz clic en **Añadir clave** para tu proveedor
|
||||
3. Introduce tu clave API y guarda
|
||||
|
||||
<Callout type="warn">
|
||||
Las claves BYOK están cifradas en reposo. Solo los administradores y propietarios de la organización pueden gestionar las claves.
|
||||
</Callout>
|
||||
|
||||
Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim Studio. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas.
|
||||
|
||||
---
|
||||
|
||||
## Single Sign-On (SSO)
|
||||
|
||||
Autenticación enterprise con soporte SAML 2.0 y OIDC para gestión centralizada de identidades.
|
||||
|
||||
### Proveedores compatibles
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- Cualquier proveedor SAML 2.0 u OIDC
|
||||
|
||||
### Configuración
|
||||
|
||||
1. Navega a **Configuración** → **SSO** en tu espacio de trabajo
|
||||
2. Elige tu proveedor de identidad
|
||||
3. Configura la conexión usando los metadatos de tu IdP
|
||||
4. Activa SSO para tu organización
|
||||
|
||||
<Callout type="info">
|
||||
Una vez que SSO está activado, los miembros del equipo se autentican a través de tu proveedor de identidad en lugar de correo electrónico/contraseña.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted
|
||||
|
||||
Para implementaciones self-hosted, las funciones enterprise se pueden activar mediante variables de entorno:
|
||||
|
||||
| Variable | Descripción |
|
||||
|----------|-------------|
|
||||
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Inicio de sesión único con SAML/OIDC |
|
||||
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Grupos de sondeo para activadores de correo electrónico |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK solo está disponible en Sim Studio alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno.
|
||||
</Callout>
|
||||
76
apps/docs/content/docs/fr/enterprise/index.mdx
Normal file
76
apps/docs/content/docs/fr/enterprise/index.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Entreprise
|
||||
description: Fonctionnalités entreprise pour les organisations ayant des
|
||||
exigences avancées en matière de sécurité et de conformité
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio Entreprise fournit des fonctionnalités avancées pour les organisations ayant des exigences renforcées en matière de sécurité, de conformité et de gestion.
|
||||
|
||||
---
|
||||
|
||||
## Apportez votre propre clé (BYOK)
|
||||
|
||||
Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim Studio.
|
||||
|
||||
### Fournisseurs pris en charge
|
||||
|
||||
| Fournisseur | Utilisation |
|
||||
|----------|-------|
|
||||
| OpenAI | Embeddings de base de connaissances, bloc Agent |
|
||||
| Anthropic | Bloc Agent |
|
||||
| Google | Bloc Agent |
|
||||
| Mistral | OCR de base de connaissances |
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Accédez à **Paramètres** → **BYOK** dans votre espace de travail
|
||||
2. Cliquez sur **Ajouter une clé** pour votre fournisseur
|
||||
3. Saisissez votre clé API et enregistrez
|
||||
|
||||
<Callout type="warn">
|
||||
Les clés BYOK sont chiffrées au repos. Seuls les administrateurs et propriétaires de l'organisation peuvent gérer les clés.
|
||||
</Callout>
|
||||
|
||||
Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim Studio. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées.
|
||||
|
||||
---
|
||||
|
||||
## Authentification unique (SSO)
|
||||
|
||||
Authentification entreprise avec prise en charge de SAML 2.0 et OIDC pour une gestion centralisée des identités.
|
||||
|
||||
### Fournisseurs pris en charge
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- Tout fournisseur SAML 2.0 ou OIDC
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Accédez à **Paramètres** → **SSO** dans votre espace de travail
|
||||
2. Choisissez votre fournisseur d'identité
|
||||
3. Configurez la connexion en utilisant les métadonnées de votre IdP
|
||||
4. Activez le SSO pour votre organisation
|
||||
|
||||
<Callout type="info">
|
||||
Une fois le SSO activé, les membres de l'équipe s'authentifient via votre fournisseur d'identité au lieu d'utiliser un email/mot de passe.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Auto-hébergé
|
||||
|
||||
Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent être activées via des variables d'environnement :
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC |
|
||||
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK est uniquement disponible sur Sim Studio hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement.
|
||||
</Callout>
|
||||
75
apps/docs/content/docs/ja/enterprise/index.mdx
Normal file
75
apps/docs/content/docs/ja/enterprise/index.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: エンタープライズ
|
||||
description: 高度なセキュリティとコンプライアンス要件を持つ組織向けのエンタープライズ機能
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
|
||||
|
||||
---
|
||||
|
||||
## Bring Your Own Key (BYOK)
|
||||
|
||||
Sim Studioのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
|
||||
|
||||
### 対応プロバイダー
|
||||
|
||||
| プロバイダー | 用途 |
|
||||
|----------|-------|
|
||||
| OpenAI | ナレッジベースの埋め込み、エージェントブロック |
|
||||
| Anthropic | エージェントブロック |
|
||||
| Google | エージェントブロック |
|
||||
| Mistral | ナレッジベースOCR |
|
||||
|
||||
### セットアップ
|
||||
|
||||
1. ワークスペースの**設定** → **BYOK**に移動します
|
||||
2. プロバイダーの**キーを追加**をクリックします
|
||||
3. APIキーを入力して保存します
|
||||
|
||||
<Callout type="warn">
|
||||
BYOKキーは保存時に暗号化されます。組織の管理者とオーナーのみがキーを管理できます。
|
||||
</Callout>
|
||||
|
||||
設定すると、ワークフローはSim Studioのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
|
||||
|
||||
---
|
||||
|
||||
## シングルサインオン (SSO)
|
||||
|
||||
集中型IDマネジメントのためのSAML 2.0およびOIDCサポートを備えたエンタープライズ認証。
|
||||
|
||||
### 対応プロバイダー
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- SAML 2.0またはOIDCに対応する任意のプロバイダー
|
||||
|
||||
### セットアップ
|
||||
|
||||
1. ワークスペースの**設定** → **SSO**に移動します
|
||||
2. IDプロバイダーを選択します
|
||||
3. IdPのメタデータを使用して接続を設定します
|
||||
4. 組織のSSOを有効にします
|
||||
|
||||
<Callout type="info">
|
||||
SSOを有効にすると、チームメンバーはメール/パスワードの代わりにIDプロバイダーを通じて認証します。
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## セルフホスト
|
||||
|
||||
セルフホストデプロイメントの場合、エンタープライズ機能は環境変数を介して有効にできます:
|
||||
|
||||
| 変数 | 説明 |
|
||||
|----------|-------------|
|
||||
| `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン |
|
||||
| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
|
||||
</Callout>
|
||||
75
apps/docs/content/docs/zh/enterprise/index.mdx
Normal file
75
apps/docs/content/docs/zh/enterprise/index.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: 企业版
|
||||
description: 为具有高级安全性和合规性需求的组织提供企业级功能
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
|
||||
|
||||
---
|
||||
|
||||
## 自带密钥(BYOK)
|
||||
|
||||
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim Studio 托管的密钥。
|
||||
|
||||
### 支持的服务商
|
||||
|
||||
| Provider | Usage |
|
||||
|----------|-------|
|
||||
| OpenAI | 知识库嵌入、Agent 模块 |
|
||||
| Anthropic | Agent 模块 |
|
||||
| Google | Agent 模块 |
|
||||
| Mistral | 知识库 OCR |
|
||||
|
||||
### 配置方法
|
||||
|
||||
1. 在您的工作区进入 **设置** → **BYOK**
|
||||
2. 为您的服务商点击 **添加密钥**
|
||||
3. 输入您的 API 密钥并保存
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK 密钥静态加密存储。仅组织管理员和所有者可管理密钥。
|
||||
</Callout>
|
||||
|
||||
配置后,工作流将使用您的密钥而非 Sim Studio 托管密钥。如移除,工作流会自动切换回托管密钥。
|
||||
|
||||
---
|
||||
|
||||
## 单点登录(SSO)
|
||||
|
||||
企业级身份认证,支持 SAML 2.0 和 OIDC,实现集中式身份管理。
|
||||
|
||||
### 支持的服务商
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- 任何 SAML 2.0 或 OIDC 服务商
|
||||
|
||||
### 配置方法
|
||||
|
||||
1. 在您的工作区进入 **设置** → **SSO**
|
||||
2. 选择您的身份提供商
|
||||
3. 使用 IdP 元数据配置连接
|
||||
4. 为您的组织启用 SSO
|
||||
|
||||
<Callout type="info">
|
||||
启用 SSO 后,团队成员将通过您的身份提供商进行身份验证,而不再使用邮箱/密码。
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## 自主部署
|
||||
|
||||
对于自主部署场景,可通过环境变量启用企业功能:
|
||||
|
||||
| 变量 | 描述 |
|
||||
|----------|-------------|
|
||||
| `SSO_ENABLED`,`NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 |
|
||||
| `CREDENTIAL_SETS_ENABLED`,`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。
|
||||
</Callout>
|
||||
@@ -50308,3 +50308,30 @@ checksums:
|
||||
content/68: ba6b5020ed971cd7ffc7f0423650dfbf
|
||||
content/69: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/70: 0362be478aa7ba4b6d1ebde0bd83e83a
|
||||
f5bc5f89ed66818f4c485c554bf26eea:
|
||||
meta/title: c70474271708e5b27392fde87462fa26
|
||||
meta/description: 7b47db7fbb818c180b99354b912a72b3
|
||||
content/0: 232be69c8f3053a40f695f9c9dcb3f2e
|
||||
content/1: a4a62a6e782e18bd863546dfcf2aec1c
|
||||
content/2: 51adf33450cab2ef392e93147386647c
|
||||
content/3: ada515cf6e2e0f9d3f57f720f79699d3
|
||||
content/4: d5e8b9f64d855675588845dc4124c491
|
||||
content/5: 3acf1f0551f6097ca6159e66f5c8da1a
|
||||
content/6: 6a6e277ded1a063ec2c2067abb519088
|
||||
content/7: 6debcd334c3310480cbe6feab87f37b5
|
||||
content/8: 0e3372052a2b3a1c43d853d6ed269d69
|
||||
content/9: 90063613714128f4e61e9588e2d2c735
|
||||
content/10: 182154179fe2a8b6b73fde0d04e0bf4c
|
||||
content/11: 51adf33450cab2ef392e93147386647c
|
||||
content/12: 73c3e8a5d36d6868fdb455fcb3d6074c
|
||||
content/13: 30cd8f1d6197bce560a091ba19d0392a
|
||||
content/14: 3acf1f0551f6097ca6159e66f5c8da1a
|
||||
content/15: 997deef758698d207be9382c45301ad6
|
||||
content/16: 6debcd334c3310480cbe6feab87f37b5
|
||||
content/17: e26c8c2dffd70baef0253720c1511886
|
||||
content/18: a99eba53979531f1c974cf653c346909
|
||||
content/19: 51adf33450cab2ef392e93147386647c
|
||||
content/20: ca3ec889fb218b8b130959ff04baa659
|
||||
content/21: 306617201cf63b42f09bb72c9722e048
|
||||
content/22: 4b48ba3f10b043f74b70edeb4ad87080
|
||||
content/23: c8531bd570711abc1963d8b5dcf9deef
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* A2A Agent Card Endpoint
|
||||
*
|
||||
* Returns the Agent Card (discovery document) for an A2A agent.
|
||||
* Also supports CRUD operations for managing agents.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { a2aAgent, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
|
||||
import type { AgentAuthentication, AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
|
||||
const logger = createLogger('A2AAgentCardAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Returns the Agent Card for discovery
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
agent: a2aAgent,
|
||||
workflow: workflow,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.innerJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!agent.agent.isPublished) {
|
||||
// Check if requester has access (for preview)
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const agentCard = generateAgentCard(
|
||||
{
|
||||
id: agent.agent.id,
|
||||
name: agent.agent.name,
|
||||
description: agent.agent.description,
|
||||
version: agent.agent.version,
|
||||
capabilities: agent.agent.capabilities as AgentCapabilities,
|
||||
skills: agent.agent.skills as AgentSkill[],
|
||||
authentication: agent.agent.authentication as AgentAuthentication,
|
||||
},
|
||||
{
|
||||
id: agent.workflow.id,
|
||||
name: agent.workflow.name,
|
||||
description: agent.workflow.description,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(agentCard, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting Agent Card:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT - Update an agent
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Update agent
|
||||
const [updatedAgent] = await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
name: body.name ?? existingAgent.name,
|
||||
description: body.description ?? existingAgent.description,
|
||||
version: body.version ?? existingAgent.version,
|
||||
capabilities: body.capabilities ?? existingAgent.capabilities,
|
||||
skills: body.skills ?? existingAgent.skills,
|
||||
authentication: body.authentication ?? existingAgent.authentication,
|
||||
isPublished: body.isPublished ?? existingAgent.isPublished,
|
||||
publishedAt:
|
||||
body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Updated A2A agent: ${agentId}`)
|
||||
|
||||
return NextResponse.json({ success: true, agent: updatedAgent })
|
||||
} catch (error) {
|
||||
logger.error('Error updating agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete an agent
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Publish/unpublish an agent
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn('A2A agent publish auth failed:', { error: auth.error, hasUserId: !!auth.userId })
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
||||
|
||||
if (action === 'publish') {
|
||||
// Verify workflow is deployed
|
||||
const [wf] = await db
|
||||
.select({ isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isDeployed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow must be deployed before publishing agent' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Published A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, isPublished: true })
|
||||
}
|
||||
|
||||
if (action === 'unpublish') {
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Unpublished A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, isPublished: false })
|
||||
}
|
||||
|
||||
if (action === 'refresh') {
|
||||
// Refresh skills from workflow
|
||||
const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
|
||||
if (!workflowData) {
|
||||
return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({ name: workflow.name, description: workflow.description })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const skills = generateSkillsFromWorkflow(
|
||||
existingAgent.workflowId,
|
||||
wf?.name || existingAgent.name,
|
||||
wf?.description,
|
||||
workflowData.blocks
|
||||
)
|
||||
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
skills,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Refreshed skills for A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, skills })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error('Error with agent action:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* A2A Agents List Endpoint
|
||||
*
|
||||
* List and create A2A agents for a workspace.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { a2aAgent, workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
|
||||
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
|
||||
import { sanitizeAgentName } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
|
||||
const logger = createLogger('A2AAgentsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - List all A2A agents for a workspace
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify workspace access
|
||||
const [ws] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get agents with workflow info
|
||||
const agents = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
workspaceId: a2aAgent.workspaceId,
|
||||
workflowId: a2aAgent.workflowId,
|
||||
name: a2aAgent.name,
|
||||
description: a2aAgent.description,
|
||||
version: a2aAgent.version,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
skills: a2aAgent.skills,
|
||||
authentication: a2aAgent.authentication,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
publishedAt: a2aAgent.publishedAt,
|
||||
createdAt: a2aAgent.createdAt,
|
||||
updatedAt: a2aAgent.updatedAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
taskCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "a2a_task"
|
||||
WHERE "a2a_task"."agent_id" = "a2a_agent"."id"
|
||||
)`.as('task_count'),
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.leftJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
|
||||
.where(eq(a2aAgent.workspaceId, workspaceId))
|
||||
.orderBy(a2aAgent.createdAt)
|
||||
|
||||
logger.info(`Listed ${agents.length} A2A agents for workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({ success: true, agents })
|
||||
} catch (error) {
|
||||
logger.error('Error listing agents:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Create a new A2A agent from a workflow
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { workspaceId, workflowId, name, description, capabilities, authentication } = body
|
||||
|
||||
if (!workspaceId || !workflowId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId and workflowId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow exists and belongs to workspace
|
||||
const [wf] = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
workspaceId: workflow.workspaceId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, workflowId), eq(workflow.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
if (!wf) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow not found or does not belong to workspace' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if agent already exists for this workflow
|
||||
const [existing] = await db
|
||||
.select({ id: a2aAgent.id })
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.workspaceId, workspaceId), eq(a2aAgent.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'An agent already exists for this workflow' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow has a start block
|
||||
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!workflowData || !hasValidStartBlockInState(workflowData)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow must have a Start block to be exposed as an A2A agent' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate skills from workflow
|
||||
const skills = generateSkillsFromWorkflow(
|
||||
workflowId,
|
||||
name || wf.name,
|
||||
description || wf.description,
|
||||
workflowData.blocks
|
||||
)
|
||||
|
||||
// Create agent
|
||||
const agentId = uuidv4()
|
||||
const agentName = name || sanitizeAgentName(wf.name)
|
||||
|
||||
const [agent] = await db
|
||||
.insert(a2aAgent)
|
||||
.values({
|
||||
id: agentId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
createdBy: auth.userId,
|
||||
name: agentName,
|
||||
description: description || wf.description,
|
||||
version: '1.0.0',
|
||||
capabilities: {
|
||||
...A2A_DEFAULT_CAPABILITIES,
|
||||
...capabilities,
|
||||
},
|
||||
skills,
|
||||
authentication: authentication || {
|
||||
schemes: ['bearer', 'apiKey'],
|
||||
},
|
||||
isPublished: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
|
||||
|
||||
return NextResponse.json({ success: true, agent }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Error creating agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,739 +0,0 @@
|
||||
/**
|
||||
* A2A Serve Endpoint - Implements A2A protocol for workflow agents
|
||||
*
|
||||
* Handles JSON-RPC 2.0 requests for:
|
||||
* - tasks/send: Create or continue a task
|
||||
* - tasks/get: Query task status
|
||||
* - tasks/cancel: Cancel a running task
|
||||
* - tasks/sendSubscribe: SSE streaming for real-time updates
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { a2aAgent, a2aTask, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { A2A_DEFAULT_TIMEOUT, A2A_METHODS } from '@/lib/a2a/constants'
|
||||
import {
|
||||
A2AErrorCode,
|
||||
type Artifact,
|
||||
type Task,
|
||||
type TaskCancelParams,
|
||||
type TaskMessage,
|
||||
type TaskQueryParams,
|
||||
type TaskSendParams,
|
||||
type TaskState,
|
||||
} from '@/lib/a2a/types'
|
||||
import {
|
||||
createAgentMessage,
|
||||
createTaskStatus,
|
||||
extractTextContent,
|
||||
formatTaskResponse,
|
||||
generateTaskId,
|
||||
isTerminalState,
|
||||
} from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('A2AServeAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
interface RouteParams {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
interface JSONRPCRequest {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
interface JSONRPCResponse {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number | null
|
||||
result?: unknown
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function createResponse(id: string | number | null, result: unknown): JSONRPCResponse {
|
||||
return { jsonrpc: '2.0', id, result }
|
||||
}
|
||||
|
||||
function createError(
|
||||
id: string | number | null,
|
||||
code: number,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): JSONRPCResponse {
|
||||
return { jsonrpc: '2.0', id, error: { code, message, data } }
|
||||
}
|
||||
|
||||
function isJSONRPCRequest(obj: unknown): obj is JSONRPCRequest {
|
||||
if (!obj || typeof obj !== 'object') return false
|
||||
const r = obj as Record<string, unknown>
|
||||
return r.jsonrpc === '2.0' && typeof r.method === 'string' && r.id !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Returns the Agent Card (discovery document)
|
||||
*
|
||||
* This allows clients to discover the agent's capabilities by calling GET on the serve endpoint.
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
name: a2aAgent.name,
|
||||
description: a2aAgent.description,
|
||||
version: a2aAgent.version,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
skills: a2aAgent.skills,
|
||||
authentication: a2aAgent.authentication,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!agent.isPublished) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
// Return full Agent Card for discovery
|
||||
return NextResponse.json(
|
||||
{
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
url: `${baseUrl}/api/a2a/serve/${agent.id}`,
|
||||
version: agent.version,
|
||||
documentationUrl: `${baseUrl}/docs/a2a`,
|
||||
provider: {
|
||||
organization: 'Sim Studio',
|
||||
url: baseUrl,
|
||||
},
|
||||
capabilities: agent.capabilities,
|
||||
skills: agent.skills,
|
||||
authentication: agent.authentication,
|
||||
defaultInputModes: ['text', 'data'],
|
||||
defaultOutputModes: ['text', 'data'],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error getting Agent Card:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Handle JSON-RPC requests
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
// Verify agent exists and is published
|
||||
const [agent] = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
name: a2aAgent.name,
|
||||
workflowId: a2aAgent.workflowId,
|
||||
workspaceId: a2aAgent.workspaceId,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2AErrorCode.AGENT_UNAVAILABLE, 'Agent not found'),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!agent.isPublished) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2AErrorCode.AGENT_UNAVAILABLE, 'Agent not published'),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Auth check
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2AErrorCode.AUTHENTICATION_REQUIRED, 'Unauthorized'),
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow is deployed
|
||||
const [wf] = await db
|
||||
.select({ isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, agent.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isDeployed) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2AErrorCode.AGENT_UNAVAILABLE, 'Workflow is not deployed'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse JSON-RPC request
|
||||
const body = await request.json()
|
||||
|
||||
if (!isJSONRPCRequest(body)) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2AErrorCode.INVALID_REQUEST, 'Invalid JSON-RPC request'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, method, params: rpcParams } = body
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
logger.info(`A2A request: ${method} for agent ${agentId}`)
|
||||
|
||||
switch (method) {
|
||||
case A2A_METHODS.TASKS_SEND:
|
||||
return handleTaskSend(id, agent, rpcParams as TaskSendParams, apiKey)
|
||||
|
||||
case A2A_METHODS.TASKS_GET:
|
||||
return handleTaskGet(id, rpcParams as TaskQueryParams)
|
||||
|
||||
case A2A_METHODS.TASKS_CANCEL:
|
||||
return handleTaskCancel(id, rpcParams as TaskCancelParams)
|
||||
|
||||
case A2A_METHODS.TASKS_SEND_SUBSCRIBE:
|
||||
return handleTaskSendSubscribe(request, id, agent, rpcParams as TaskSendParams, apiKey)
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
createError(id, A2AErrorCode.METHOD_NOT_FOUND, `Method not found: ${method}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling A2A request:', error)
|
||||
return NextResponse.json(createError(null, A2AErrorCode.INTERNAL_ERROR, 'Internal error'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tasks/send - Create or continue a task
|
||||
*/
|
||||
async function handleTaskSend(
|
||||
id: string | number,
|
||||
agent: {
|
||||
id: string
|
||||
name: string
|
||||
workflowId: string
|
||||
workspaceId: string
|
||||
},
|
||||
params: TaskSendParams,
|
||||
apiKey?: string | null
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.message) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Message is required'), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const taskId = params.id || generateTaskId()
|
||||
const contextId = params.contextId
|
||||
|
||||
// Check if task exists (continuation)
|
||||
let existingTask: typeof a2aTask.$inferSelect | null = null
|
||||
if (params.id) {
|
||||
const [found] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
existingTask = found || null
|
||||
|
||||
if (!existingTask) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
if (isTerminalState(existingTask.status as TaskState)) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2AErrorCode.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing history or start fresh
|
||||
const history: TaskMessage[] = existingTask?.messages
|
||||
? (existingTask.messages as TaskMessage[])
|
||||
: []
|
||||
|
||||
// Add the new user message
|
||||
history.push(params.message)
|
||||
|
||||
// Create or update task
|
||||
if (existingTask) {
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: 'working',
|
||||
messages: history,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, taskId))
|
||||
} else {
|
||||
await db.insert(a2aTask).values({
|
||||
id: taskId,
|
||||
agentId: agent.id,
|
||||
sessionId: contextId || null,
|
||||
status: 'working',
|
||||
messages: history,
|
||||
metadata: params.metadata || {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
const executeUrl = `${getBaseUrl()}/api/workflows/${agent.workflowId}/execute`
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['X-API-Key'] = apiKey
|
||||
|
||||
logger.info(`Executing workflow ${agent.workflowId} for A2A task ${taskId}`)
|
||||
|
||||
try {
|
||||
// Extract text content from the TaskMessage for easier workflow consumption
|
||||
const messageText = extractTextContent(params.message)
|
||||
|
||||
const response = await fetch(executeUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
input: messageText,
|
||||
triggerType: 'api',
|
||||
}),
|
||||
signal: AbortSignal.timeout(A2A_DEFAULT_TIMEOUT),
|
||||
})
|
||||
|
||||
const executeResult = await response.json()
|
||||
|
||||
// Determine final state
|
||||
const finalState: TaskState = response.ok ? 'completed' : 'failed'
|
||||
|
||||
// Create agent response message
|
||||
const agentContent =
|
||||
executeResult.output?.content ||
|
||||
(typeof executeResult.output === 'object'
|
||||
? JSON.stringify(executeResult.output)
|
||||
: String(executeResult.output || executeResult.error || 'Task completed'))
|
||||
|
||||
const agentMessage = createAgentMessage(agentContent)
|
||||
history.push(agentMessage)
|
||||
|
||||
// Extract artifacts if present
|
||||
const artifacts = executeResult.output?.artifacts || []
|
||||
|
||||
// Update task with result
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: finalState,
|
||||
messages: history,
|
||||
artifacts,
|
||||
executionId: executeResult.metadata?.executionId,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, taskId))
|
||||
|
||||
const task: Task = {
|
||||
id: taskId,
|
||||
contextId: contextId || undefined,
|
||||
status: createTaskStatus(finalState),
|
||||
history,
|
||||
artifacts,
|
||||
metadata: params.metadata,
|
||||
kind: 'task',
|
||||
}
|
||||
|
||||
return NextResponse.json(createResponse(id, task))
|
||||
} catch (error) {
|
||||
logger.error(`Error executing workflow for task ${taskId}:`, error)
|
||||
|
||||
// Mark task as failed
|
||||
const errorMessage = error instanceof Error ? error.message : 'Workflow execution failed'
|
||||
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: 'failed',
|
||||
updatedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, taskId))
|
||||
|
||||
return NextResponse.json(createError(id, A2AErrorCode.INTERNAL_ERROR, errorMessage), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tasks/get - Query task status
|
||||
*/
|
||||
async function handleTaskGet(id: string | number, params: TaskQueryParams): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Task ID is required'), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate historyLength if provided
|
||||
const historyLength =
|
||||
params.historyLength !== undefined && params.historyLength >= 0
|
||||
? params.historyLength
|
||||
: undefined
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
const result = formatTaskResponse(
|
||||
{
|
||||
id: task.id,
|
||||
contextId: task.sessionId || undefined,
|
||||
status: createTaskStatus(task.status as TaskState),
|
||||
history: task.messages as TaskMessage[],
|
||||
artifacts: (task.artifacts as Artifact[]) || [],
|
||||
metadata: (task.metadata as Record<string, unknown>) || {},
|
||||
kind: 'task',
|
||||
},
|
||||
historyLength
|
||||
)
|
||||
|
||||
return NextResponse.json(createResponse(id, result))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tasks/cancel - Cancel a running task
|
||||
*/
|
||||
async function handleTaskCancel(
|
||||
id: string | number,
|
||||
params: TaskCancelParams
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Task ID is required'), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
if (isTerminalState(task.status as TaskState)) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2AErrorCode.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: 'canceled',
|
||||
updatedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, params.id))
|
||||
|
||||
const result: Task = {
|
||||
id: task.id,
|
||||
contextId: task.sessionId || undefined,
|
||||
status: createTaskStatus('canceled'),
|
||||
history: task.messages as TaskMessage[],
|
||||
artifacts: (task.artifacts as Artifact[]) || [],
|
||||
kind: 'task',
|
||||
}
|
||||
|
||||
return NextResponse.json(createResponse(id, result))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tasks/sendSubscribe - SSE streaming
|
||||
*/
|
||||
async function handleTaskSendSubscribe(
|
||||
request: NextRequest,
|
||||
id: string | number,
|
||||
agent: {
|
||||
id: string
|
||||
name: string
|
||||
workflowId: string
|
||||
workspaceId: string
|
||||
},
|
||||
params: TaskSendParams,
|
||||
apiKey?: string | null
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.message) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Message is required'), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const contextId = params.contextId
|
||||
|
||||
// Get existing task or prepare for new one
|
||||
let history: TaskMessage[] = []
|
||||
let existingTask: typeof a2aTask.$inferSelect | null = null
|
||||
|
||||
if (params.id) {
|
||||
const [found] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
existingTask = found || null
|
||||
|
||||
if (!existingTask) {
|
||||
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
if (isTerminalState(existingTask.status as TaskState)) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2AErrorCode.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
history = existingTask.messages as TaskMessage[]
|
||||
}
|
||||
|
||||
const taskId = params.id || generateTaskId()
|
||||
history.push(params.message)
|
||||
|
||||
// Create or update task record
|
||||
if (existingTask) {
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: 'working',
|
||||
messages: history,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, taskId))
|
||||
} else {
|
||||
await db.insert(a2aTask).values({
|
||||
id: taskId,
|
||||
agentId: agent.id,
|
||||
sessionId: contextId || null,
|
||||
status: 'working',
|
||||
messages: history,
|
||||
metadata: params.metadata || {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
// Create SSE stream
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
|
||||
} catch (error) {
|
||||
logger.error('Error sending SSE event:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial status
|
||||
sendEvent('task:status', {
|
||||
id: taskId,
|
||||
status: { state: 'working', timestamp: new Date().toISOString() },
|
||||
})
|
||||
|
||||
try {
|
||||
// Execute workflow with streaming
|
||||
const executeUrl = `${getBaseUrl()}/api/workflows/${agent.workflowId}/execute`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Stream-Response': 'true',
|
||||
}
|
||||
if (apiKey) headers['X-API-Key'] = apiKey
|
||||
|
||||
// Extract text content from the TaskMessage for easier workflow consumption
|
||||
const messageText = extractTextContent(params.message)
|
||||
|
||||
const response = await fetch(executeUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
input: messageText,
|
||||
triggerType: 'api',
|
||||
stream: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(A2A_DEFAULT_TIMEOUT),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Workflow execution failed'
|
||||
try {
|
||||
const errorResult = await response.json()
|
||||
errorMessage = errorResult.error || errorMessage
|
||||
} catch {
|
||||
// Response may not be JSON
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
// Check content type to determine response handling
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const isStreamingResponse =
|
||||
contentType.includes('text/event-stream') || contentType.includes('text/plain')
|
||||
|
||||
if (response.body && isStreamingResponse) {
|
||||
// Handle streaming response - forward chunks
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let fullContent = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
fullContent += chunk
|
||||
|
||||
// Forward chunk as message event
|
||||
sendEvent('task:message', {
|
||||
id: taskId,
|
||||
chunk,
|
||||
})
|
||||
}
|
||||
|
||||
// Create final agent message
|
||||
const agentMessage = createAgentMessage(fullContent || 'Task completed')
|
||||
history.push(agentMessage)
|
||||
|
||||
// Update task
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: 'completed',
|
||||
messages: history,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, taskId))
|
||||
|
||||
sendEvent('task:status', {
|
||||
id: taskId,
|
||||
status: { state: 'completed', timestamp: new Date().toISOString() },
|
||||
final: true,
|
||||
})
|
||||
} else {
|
||||
// Handle JSON response (non-streaming workflow)
|
||||
const result = await response.json()
|
||||
|
||||
const content =
|
||||
result.output?.content ||
|
||||
(typeof result.output === 'object'
|
||||
? JSON.stringify(result.output)
|
||||
: String(result.output || 'Task completed'))
|
||||
|
||||
// Send the complete content as a single message
|
||||
sendEvent('task:message', {
|
||||
id: taskId,
|
||||
chunk: content,
|
||||
})
|
||||
|
||||
const agentMessage = createAgentMessage(content)
|
||||
history.push(agentMessage)
|
||||
|
||||
const artifacts = (result.output?.artifacts as Artifact[]) || []
|
||||
|
||||
// Update task with result
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: 'completed',
|
||||
messages: history,
|
||||
artifacts,
|
||||
executionId: result.metadata?.executionId,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, taskId))
|
||||
|
||||
sendEvent('task:status', {
|
||||
id: taskId,
|
||||
status: { state: 'completed', timestamp: new Date().toISOString() },
|
||||
final: true,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Streaming error for task ${taskId}:`, error)
|
||||
|
||||
await db
|
||||
.update(a2aTask)
|
||||
.set({
|
||||
status: 'failed',
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aTask.id, taskId))
|
||||
|
||||
sendEvent('error', {
|
||||
code: A2AErrorCode.INTERNAL_ERROR,
|
||||
message: error instanceof Error ? error.message : 'Streaming failed',
|
||||
})
|
||||
} finally {
|
||||
sendEvent('task:done', { id: taskId })
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
...SSE_HEADERS,
|
||||
'X-Task-Id': taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { auth, getSession } from '@/lib/auth'
|
||||
import { hasSSOAccess } from '@/lib/billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
|
||||
@@ -63,10 +64,22 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// SSO plugin must be enabled in Better Auth
|
||||
if (!env.SSO_ENABLED) {
|
||||
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check plan access (enterprise) or env var override
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const hasAccess = await hasSSOAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
|
||||
}
|
||||
|
||||
const rawBody = await request.json()
|
||||
|
||||
const parseResult = ssoRegistrationSchema.safeParse(rawBody)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -45,6 +46,15 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, invitationId } = await params
|
||||
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -47,6 +48,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getCredentialSetWithAccess(id, session.user.id)
|
||||
|
||||
@@ -69,6 +79,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
@@ -178,6 +197,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const invitationId = searchParams.get('invitationId')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
|
||||
const logger = createLogger('CredentialSetMembers')
|
||||
@@ -39,6 +40,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getCredentialSetWithAccess(id, session.user.id)
|
||||
|
||||
@@ -110,6 +120,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const memberId = searchParams.get('memberId')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('CredentialSet')
|
||||
|
||||
@@ -49,6 +50,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getCredentialSetWithAccess(id, session.user.id)
|
||||
|
||||
@@ -66,6 +76,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
@@ -129,6 +148,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, count, desc, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('CredentialSets')
|
||||
|
||||
@@ -22,6 +23,15 @@ export async function GET(req: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
@@ -85,6 +95,15 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body)
|
||||
|
||||
199
apps/sim/app/api/v1/admin/byok/route.ts
Normal file
199
apps/sim/app/api/v1/admin/byok/route.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Admin BYOK Keys API
|
||||
*
|
||||
* GET /api/v1/admin/byok
|
||||
* List all BYOK keys with optional filtering.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId?: string - Filter by organization ID (finds all workspaces billed to this org)
|
||||
* - workspaceId?: string - Filter by specific workspace ID
|
||||
*
|
||||
* Response: { data: AdminBYOKKey[], pagination: PaginationMeta }
|
||||
*
|
||||
* DELETE /api/v1/admin/byok
|
||||
* Delete BYOK keys for an organization or workspace.
|
||||
* Used when an enterprise plan churns to clean up BYOK keys.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId: string - Delete all BYOK keys for workspaces billed to this org
|
||||
* - workspaceId?: string - Delete keys for a specific workspace only (optional)
|
||||
*
|
||||
* Response: { success: true, deletedCount: number, workspacesAffected: string[] }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { user, workspace, workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray, sql } from 'drizzle-orm'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminBYOKAPI')
|
||||
|
||||
export interface AdminBYOKKey {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
organizationId: string
|
||||
providerId: string
|
||||
createdAt: string
|
||||
createdByUserId: string | null
|
||||
createdByEmail: string | null
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
let workspaceIds: string[] = []
|
||||
|
||||
if (workspaceId) {
|
||||
workspaceIds = [workspaceId]
|
||||
} else if (organizationId) {
|
||||
const workspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.billedAccountUserId, organizationId))
|
||||
|
||||
workspaceIds = workspaces.map((w) => w.id)
|
||||
}
|
||||
|
||||
const query = db
|
||||
.select({
|
||||
id: workspaceBYOKKeys.id,
|
||||
workspaceId: workspaceBYOKKeys.workspaceId,
|
||||
workspaceName: workspace.name,
|
||||
organizationId: workspace.billedAccountUserId,
|
||||
providerId: workspaceBYOKKeys.providerId,
|
||||
createdAt: workspaceBYOKKeys.createdAt,
|
||||
createdByUserId: workspaceBYOKKeys.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(workspaceBYOKKeys)
|
||||
.innerJoin(workspace, eq(workspaceBYOKKeys.workspaceId, workspace.id))
|
||||
.leftJoin(user, eq(workspaceBYOKKeys.createdBy, user.id))
|
||||
|
||||
let keys
|
||||
if (workspaceIds.length > 0) {
|
||||
keys = await query.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
|
||||
} else {
|
||||
keys = await query
|
||||
}
|
||||
|
||||
const formattedKeys: AdminBYOKKey[] = keys.map((k) => ({
|
||||
id: k.id,
|
||||
workspaceId: k.workspaceId,
|
||||
workspaceName: k.workspaceName,
|
||||
organizationId: k.organizationId,
|
||||
providerId: k.providerId,
|
||||
createdAt: k.createdAt.toISOString(),
|
||||
createdByUserId: k.createdByUserId,
|
||||
createdByEmail: k.createdByEmail,
|
||||
}))
|
||||
|
||||
logger.info('Admin API: Listed BYOK keys', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
count: formattedKeys.length,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
data: formattedKeys,
|
||||
pagination: {
|
||||
total: formattedKeys.length,
|
||||
limit: formattedKeys.length,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list BYOK keys', { error, organizationId, workspaceId })
|
||||
return internalErrorResponse('Failed to list BYOK keys')
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
|
||||
|
||||
if (!organizationId && !workspaceId) {
|
||||
return badRequestResponse('Either organizationId or workspaceId is required')
|
||||
}
|
||||
|
||||
try {
|
||||
let workspaceIds: string[] = []
|
||||
|
||||
if (workspaceId) {
|
||||
workspaceIds = [workspaceId]
|
||||
} else if (organizationId) {
|
||||
const workspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.billedAccountUserId, organizationId))
|
||||
|
||||
workspaceIds = workspaces.map((w) => w.id)
|
||||
}
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
logger.info('Admin API: No workspaces found for BYOK cleanup', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
})
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
workspacesAffected: [],
|
||||
message: 'No workspaces found for the given organization/workspace ID',
|
||||
})
|
||||
}
|
||||
|
||||
const countResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(workspaceBYOKKeys)
|
||||
.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
|
||||
|
||||
const totalToDelete = Number(countResult[0]?.count ?? 0)
|
||||
|
||||
if (totalToDelete === 0) {
|
||||
logger.info('Admin API: No BYOK keys to delete', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
workspaceIds,
|
||||
})
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
workspacesAffected: [],
|
||||
message: 'No BYOK keys found for the specified workspaces',
|
||||
})
|
||||
}
|
||||
|
||||
await db.delete(workspaceBYOKKeys).where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
|
||||
|
||||
logger.info('Admin API: Deleted BYOK keys', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
workspaceIds,
|
||||
deletedCount: totalToDelete,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: totalToDelete,
|
||||
workspacesAffected: workspaceIds,
|
||||
reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete BYOK keys', { error, organizationId, workspaceId })
|
||||
return internalErrorResponse('Failed to delete BYOK keys')
|
||||
}
|
||||
})
|
||||
@@ -51,6 +51,10 @@
|
||||
* GET /api/v1/admin/subscriptions - List all subscriptions
|
||||
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
||||
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
|
||||
*
|
||||
* BYOK Keys:
|
||||
* GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
|
||||
* DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
|
||||
*/
|
||||
|
||||
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
|
||||
|
||||
@@ -744,7 +744,7 @@ export async function POST(request: NextRequest) {
|
||||
if (savedWebhook && provider === 'grain') {
|
||||
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
|
||||
try {
|
||||
const grainHookId = await createGrainWebhookSubscription(
|
||||
const grainResult = await createGrainWebhookSubscription(
|
||||
request,
|
||||
{
|
||||
id: savedWebhook.id,
|
||||
@@ -754,11 +754,12 @@ export async function POST(request: NextRequest) {
|
||||
requestId
|
||||
)
|
||||
|
||||
if (grainHookId) {
|
||||
// Update the webhook record with the external Grain hook ID
|
||||
if (grainResult) {
|
||||
// Update the webhook record with the external Grain hook ID and event types for filtering
|
||||
const updatedConfig = {
|
||||
...(savedWebhook.providerConfig as Record<string, any>),
|
||||
externalId: grainHookId,
|
||||
externalId: grainResult.id,
|
||||
eventTypes: grainResult.eventTypes,
|
||||
}
|
||||
await db
|
||||
.update(webhook)
|
||||
@@ -770,7 +771,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
savedWebhook.providerConfig = updatedConfig
|
||||
logger.info(`[${requestId}] Successfully created Grain webhook`, {
|
||||
grainHookId,
|
||||
grainHookId: grainResult.id,
|
||||
eventTypes: grainResult.eventTypes,
|
||||
webhookId: savedWebhook.id,
|
||||
})
|
||||
}
|
||||
@@ -1176,10 +1178,10 @@ async function createGrainWebhookSubscription(
|
||||
request: NextRequest,
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
): Promise<string | undefined> {
|
||||
): Promise<{ id: string; eventTypes: string[] } | undefined> {
|
||||
try {
|
||||
const { path, providerConfig } = webhookData
|
||||
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
|
||||
const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } =
|
||||
providerConfig || {}
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -1191,12 +1193,53 @@ async function createGrainWebhookSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
// Map trigger IDs to Grain API hook_type (only 2 options: recording_added, upload_status)
|
||||
const hookTypeMap: Record<string, string> = {
|
||||
grain_webhook: 'recording_added',
|
||||
grain_recording_created: 'recording_added',
|
||||
grain_recording_updated: 'recording_added',
|
||||
grain_highlight_created: 'recording_added',
|
||||
grain_highlight_updated: 'recording_added',
|
||||
grain_story_created: 'recording_added',
|
||||
grain_upload_status: 'upload_status',
|
||||
}
|
||||
|
||||
const eventTypeMap: Record<string, string[]> = {
|
||||
grain_webhook: [],
|
||||
grain_recording_created: ['recording_added'],
|
||||
grain_recording_updated: ['recording_updated'],
|
||||
grain_highlight_created: ['highlight_created'],
|
||||
grain_highlight_updated: ['highlight_updated'],
|
||||
grain_story_created: ['story_created'],
|
||||
grain_upload_status: ['upload_status'],
|
||||
}
|
||||
|
||||
const hookType = hookTypeMap[triggerId] ?? 'recording_added'
|
||||
const eventTypes = eventTypeMap[triggerId] ?? []
|
||||
|
||||
if (!hookTypeMap[triggerId]) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`,
|
||||
{
|
||||
webhookId: webhookData.id,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Creating Grain webhook`, {
|
||||
triggerId,
|
||||
hookType,
|
||||
eventTypes,
|
||||
webhookId: webhookData.id,
|
||||
})
|
||||
|
||||
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,
|
||||
hook_type: hookType,
|
||||
}
|
||||
|
||||
// Build include object based on configuration
|
||||
@@ -1226,8 +1269,10 @@ async function createGrainWebhookSubscription(
|
||||
|
||||
const responseBody = await grainResponse.json()
|
||||
|
||||
if (!grainResponse.ok || responseBody.error) {
|
||||
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
|
||||
logger.warn('[App] Grain response body:', responseBody)
|
||||
const errorMessage =
|
||||
responseBody.errors?.detail ||
|
||||
responseBody.error?.message ||
|
||||
responseBody.error ||
|
||||
responseBody.message ||
|
||||
@@ -1255,10 +1300,11 @@ async function createGrainWebhookSubscription(
|
||||
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
|
||||
{
|
||||
grainWebhookId: responseBody.id,
|
||||
eventTypes,
|
||||
}
|
||||
)
|
||||
|
||||
return responseBody.id
|
||||
return { id: responseBody.id, eventTypes }
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -56,6 +58,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let byokEnabled = true
|
||||
if (isHosted) {
|
||||
byokEnabled = await isEnterpriseOrgAdminOrOwner(userId)
|
||||
}
|
||||
|
||||
if (!byokEnabled) {
|
||||
return NextResponse.json({ keys: [], byokEnabled: false })
|
||||
}
|
||||
|
||||
const byokKeys = await db
|
||||
.select({
|
||||
id: workspaceBYOKKeys.id,
|
||||
@@ -97,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ keys: formattedKeys })
|
||||
return NextResponse.json({ keys: formattedKeys, byokEnabled: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] BYOK keys GET error`, error)
|
||||
return NextResponse.json(
|
||||
@@ -120,6 +131,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
if (isHosted) {
|
||||
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
|
||||
if (!canManageBYOK) {
|
||||
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
@@ -220,6 +245,20 @@ export async function DELETE(
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
if (isHosted) {
|
||||
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
|
||||
if (!canManageBYOK) {
|
||||
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -175,7 +175,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
setShowScrollButton(distanceFromBottom > 100)
|
||||
|
||||
// Track if user is manually scrolling during streaming
|
||||
if (isStreamingResponse && !isUserScrollingRef.current) {
|
||||
setUserHasScrolled(true)
|
||||
}
|
||||
@@ -191,13 +190,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [handleScroll])
|
||||
|
||||
// Reset user scroll tracking when streaming starts
|
||||
useEffect(() => {
|
||||
if (isStreamingResponse) {
|
||||
// Reset userHasScrolled when streaming starts
|
||||
setUserHasScrolled(false)
|
||||
|
||||
// Give a small delay to distinguish between programmatic scroll and user scroll
|
||||
isUserScrollingRef.current = true
|
||||
setTimeout(() => {
|
||||
isUserScrollingRef.current = false
|
||||
@@ -215,7 +211,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Check if auth is required
|
||||
if (response.status === 401) {
|
||||
const errorData = await response.json()
|
||||
|
||||
@@ -236,7 +231,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
throw new Error(`Failed to load chat configuration: ${response.status}`)
|
||||
}
|
||||
|
||||
// Reset auth required state when authentication is successful
|
||||
setAuthRequired(null)
|
||||
|
||||
const data = await response.json()
|
||||
@@ -260,7 +254,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch chat config on mount and generate new conversation ID
|
||||
useEffect(() => {
|
||||
fetchChatConfig()
|
||||
setConversationId(uuidv4())
|
||||
@@ -285,7 +278,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}, 800)
|
||||
}
|
||||
|
||||
// Handle sending a message
|
||||
const handleSendMessage = async (
|
||||
messageParam?: string,
|
||||
isVoiceInput = false,
|
||||
@@ -308,7 +300,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
filesCount: files?.length,
|
||||
})
|
||||
|
||||
// Reset userHasScrolled when sending a new message
|
||||
setUserHasScrolled(false)
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
@@ -325,24 +316,20 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
})),
|
||||
}
|
||||
|
||||
// Add the user's message to the chat
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Scroll to show only the user's message and loading indicator
|
||||
setTimeout(() => {
|
||||
scrollToMessage(userMessage.id, true)
|
||||
}, 100)
|
||||
|
||||
// Create abort controller for request cancellation
|
||||
const abortController = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort()
|
||||
}, CHAT_REQUEST_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
// Send structured payload to maintain chat context
|
||||
const payload: any = {
|
||||
input:
|
||||
typeof userMessage.content === 'string'
|
||||
@@ -351,7 +338,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
conversationId,
|
||||
}
|
||||
|
||||
// Add files if present (convert to base64 for JSON transmission)
|
||||
if (files && files.length > 0) {
|
||||
payload.files = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
@@ -379,7 +365,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
// Clear timeout since request succeeded
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -392,7 +377,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
throw new Error('Response body is missing')
|
||||
}
|
||||
|
||||
// Use the streaming hook with audio support
|
||||
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
|
||||
const audioHandler = shouldPlayAudio
|
||||
? createAudioStreamHandler(
|
||||
@@ -421,7 +405,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}
|
||||
)
|
||||
} catch (error: any) {
|
||||
// Clear timeout in case of error
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
@@ -442,7 +425,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop audio when component unmounts or when streaming is stopped
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopAudio()
|
||||
@@ -452,28 +434,23 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}
|
||||
}, [stopAudio])
|
||||
|
||||
// Voice interruption - stop audio when user starts speaking
|
||||
const handleVoiceInterruption = useCallback(() => {
|
||||
stopAudio()
|
||||
|
||||
// Stop any ongoing streaming response
|
||||
if (isStreamingResponse) {
|
||||
stopStreaming(setMessages)
|
||||
}
|
||||
}, [isStreamingResponse, stopStreaming, setMessages, stopAudio])
|
||||
|
||||
// Handle voice mode activation
|
||||
const handleVoiceStart = useCallback(() => {
|
||||
setIsVoiceFirstMode(true)
|
||||
}, [])
|
||||
|
||||
// Handle exiting voice mode
|
||||
const handleExitVoiceMode = useCallback(() => {
|
||||
setIsVoiceFirstMode(false)
|
||||
stopAudio() // Stop any playing audio when exiting
|
||||
stopAudio()
|
||||
}, [stopAudio])
|
||||
|
||||
// Handle voice transcript from voice-first interface
|
||||
const handleVoiceTranscript = useCallback(
|
||||
(transcript: string) => {
|
||||
logger.info('Received voice transcript:', transcript)
|
||||
@@ -482,14 +459,11 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
[handleSendMessage]
|
||||
)
|
||||
|
||||
// If error, show error message using the extracted component
|
||||
if (error) {
|
||||
return <ChatErrorState error={error} starCount={starCount} />
|
||||
}
|
||||
|
||||
// If authentication is required, use the extracted components
|
||||
if (authRequired) {
|
||||
// Get title and description from the URL params or use defaults
|
||||
const title = new URLSearchParams(window.location.search).get('title') || 'chat'
|
||||
const primaryColor =
|
||||
new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
|
||||
@@ -526,12 +500,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state while fetching config using the extracted component
|
||||
if (!chatConfig) {
|
||||
return <ChatLoadingState />
|
||||
}
|
||||
|
||||
// Voice-first mode interface
|
||||
if (isVoiceFirstMode) {
|
||||
return (
|
||||
<VoiceInterface
|
||||
@@ -551,7 +523,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Standard text-based chat interface
|
||||
return (
|
||||
<div className='fixed inset-0 z-[100] flex flex-col bg-white text-foreground'>
|
||||
{/* Header component */}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate } from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { Dashboard } from './dashboard'
|
||||
export { LogDetails } from './log-details'
|
||||
export { ExecutionSnapshot } from './log-details/components/execution-snapshot'
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
BlockDetailsSidebar,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface TraceSpan {
|
||||
blockId?: string
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
status?: string
|
||||
duration?: number
|
||||
children?: TraceSpan[]
|
||||
}
|
||||
|
||||
interface BlockExecutionData {
|
||||
input: unknown
|
||||
output: unknown
|
||||
status: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
interface MigratedWorkflowState extends WorkflowState {
|
||||
_migrated: true
|
||||
_note?: string
|
||||
}
|
||||
|
||||
function isMigratedWorkflowState(state: WorkflowState): state is MigratedWorkflowState {
|
||||
return (state as MigratedWorkflowState)._migrated === true
|
||||
}
|
||||
|
||||
interface ExecutionSnapshotProps {
|
||||
executionId: string
|
||||
traceSpans?: TraceSpan[]
|
||||
className?: string
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
isModal?: boolean
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function ExecutionSnapshot({
|
||||
executionId,
|
||||
traceSpans,
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
isModal = false,
|
||||
isOpen = false,
|
||||
onClose = () => {},
|
||||
}: ExecutionSnapshotProps) {
|
||||
const { data, isLoading, error } = useExecutionSnapshot(executionId)
|
||||
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
|
||||
|
||||
const blockExecutions = useMemo(() => {
|
||||
if (!traceSpans || !Array.isArray(traceSpans)) return {}
|
||||
|
||||
const blockExecutionMap: Record<string, BlockExecutionData> = {}
|
||||
|
||||
const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => {
|
||||
const blockSpans: TraceSpan[] = []
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.blockId) {
|
||||
blockSpans.push(span)
|
||||
}
|
||||
if (span.children && Array.isArray(span.children)) {
|
||||
blockSpans.push(...collectBlockSpans(span.children))
|
||||
}
|
||||
}
|
||||
|
||||
return blockSpans
|
||||
}
|
||||
|
||||
const allBlockSpans = collectBlockSpans(traceSpans)
|
||||
|
||||
for (const span of allBlockSpans) {
|
||||
if (span.blockId && !blockExecutionMap[span.blockId]) {
|
||||
blockExecutionMap[span.blockId] = {
|
||||
input: redactApiKeys(span.input || {}),
|
||||
output: redactApiKeys(span.output || {}),
|
||||
status: span.status || 'unknown',
|
||||
durationMs: span.duration || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blockExecutionMap
|
||||
}, [traceSpans])
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedBlockId(null)
|
||||
}, [executionId])
|
||||
|
||||
const workflowState = data?.workflowState as WorkflowState | undefined
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||
<span className='text-[13px]'>Loading execution snapshot...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-[16px] w-[16px]' />
|
||||
<span className='text-[13px]'>Failed to load execution snapshot: {error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || !workflowState) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||
<span className='text-[13px]'>Loading execution snapshot...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMigratedWorkflowState(workflowState)) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
|
||||
<AlertCircle className='h-[20px] w-[20px]' />
|
||||
<span className='font-medium text-[15px]'>Logged State Not Found</span>
|
||||
</div>
|
||||
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
|
||||
This log was migrated from the old logging system. The workflow state at execution time
|
||||
is not available.
|
||||
</div>
|
||||
<div className='text-[12px] text-[var(--text-tertiary)]'>Note: {workflowState._note}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className={cn(
|
||||
'flex overflow-hidden rounded-[4px] border border-[var(--border)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='h-full flex-1'>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={(blockId) => {
|
||||
setPinnedBlockId((prev) => (prev === blockId ? null : blockId))
|
||||
}}
|
||||
cursorStyle='pointer'
|
||||
executedBlocks={blockExecutions}
|
||||
/>
|
||||
</div>
|
||||
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
block={workflowState.blocks[pinnedBlockId]}
|
||||
executionData={blockExecutions[pinnedBlockId]}
|
||||
allBlockExecutions={blockExecutions}
|
||||
workflowBlocks={workflowState.blocks}
|
||||
isExecutionMode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isModal) {
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPinnedBlockId(null)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
|
||||
<ModalBody className='!p-0 min-h-0 flex-1'>{renderContent()}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return renderContent()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ExecutionSnapshot } from './execution-snapshot'
|
||||
@@ -1,657 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Hash,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { Badge, Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('FrozenCanvas')
|
||||
|
||||
function ExpandableDataSection({ title, data }: { title: string; data: any }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const jsonString = JSON.stringify(data, null, 2)
|
||||
const isLargeData = jsonString.length > 500 || jsonString.split('\n').length > 10
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className='mb-[6px] flex items-center justify-between'>
|
||||
<h4 className='font-medium text-[13px] text-[var(--text-primary)]'>{title}</h4>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{isLargeData && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
title='Expand in modal'
|
||||
type='button'
|
||||
>
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<ChevronDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-y-auto rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] font-mono text-[12px] transition-all duration-200',
|
||||
isExpanded ? 'max-h-96' : 'max-h-32'
|
||||
)}
|
||||
>
|
||||
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)]'>
|
||||
{jsonString}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
|
||||
<div className='mx-[16px] flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b p-[16px]'>
|
||||
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>{title}</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex-1 overflow-auto p-[16px]'>
|
||||
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)]'>
|
||||
{jsonString}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatExecutionData(executionData: any) {
|
||||
const {
|
||||
inputData,
|
||||
outputData,
|
||||
cost,
|
||||
tokens,
|
||||
durationMs,
|
||||
status,
|
||||
blockName,
|
||||
blockType,
|
||||
errorMessage,
|
||||
errorStackTrace,
|
||||
} = executionData
|
||||
|
||||
return {
|
||||
blockName: blockName || 'Unknown Block',
|
||||
blockType: blockType || 'unknown',
|
||||
status,
|
||||
duration: durationMs ? `${durationMs}ms` : 'N/A',
|
||||
input: redactApiKeys(inputData || {}),
|
||||
output: redactApiKeys(outputData || {}),
|
||||
errorMessage,
|
||||
errorStackTrace,
|
||||
cost: cost
|
||||
? {
|
||||
input: cost.input || 0,
|
||||
output: cost.output || 0,
|
||||
total: cost.total || 0,
|
||||
}
|
||||
: null,
|
||||
tokens: tokens
|
||||
? {
|
||||
input: tokens.input || tokens.prompt || 0,
|
||||
output: tokens.output || tokens.completion || 0,
|
||||
total: tokens.total || 0,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentIterationData(blockExecutionData: any) {
|
||||
if (blockExecutionData.iterations && Array.isArray(blockExecutionData.iterations)) {
|
||||
const currentIndex = blockExecutionData.currentIteration ?? 0
|
||||
return {
|
||||
executionData: blockExecutionData.iterations[currentIndex],
|
||||
currentIteration: currentIndex,
|
||||
totalIterations: blockExecutionData.totalIterations ?? blockExecutionData.iterations.length,
|
||||
hasMultipleIterations: blockExecutionData.iterations.length > 1,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
executionData: blockExecutionData,
|
||||
currentIteration: 0,
|
||||
totalIterations: 1,
|
||||
hasMultipleIterations: false,
|
||||
}
|
||||
}
|
||||
|
||||
function PinnedLogs({
|
||||
executionData,
|
||||
blockId,
|
||||
workflowState,
|
||||
onClose,
|
||||
}: {
|
||||
executionData: any | null
|
||||
blockId: string
|
||||
workflowState: any
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIterationIndex(0)
|
||||
}, [executionData])
|
||||
|
||||
if (!executionData) {
|
||||
const blockInfo = workflowState?.blocks?.[blockId]
|
||||
const formatted = {
|
||||
blockName: blockInfo?.name || 'Unknown Block',
|
||||
blockType: blockInfo?.type || 'unknown',
|
||||
status: 'not_executed',
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||
<CardHeader className='pb-[12px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
|
||||
<Zap className='h-[16px] w-[16px]' />
|
||||
{formatted.blockName}
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Badge variant='gray-secondary'>{formatted.blockType}</Badge>
|
||||
<Badge variant='outline'>not executed</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-[16px]'>
|
||||
<div className='rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px] text-center'>
|
||||
<div className='text-[13px] text-[var(--text-secondary)]'>
|
||||
This block was not executed because the workflow failed before reaching it.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const iterationInfo = getCurrentIterationData({
|
||||
...executionData,
|
||||
currentIteration: currentIterationIndex,
|
||||
})
|
||||
|
||||
const formatted = formatExecutionData(iterationInfo.executionData)
|
||||
const totalIterations = executionData.iterations?.length || 1
|
||||
|
||||
const goToPreviousIteration = () => {
|
||||
if (currentIterationIndex > 0) {
|
||||
setCurrentIterationIndex(currentIterationIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNextIteration = () => {
|
||||
if (currentIterationIndex < totalIterations - 1) {
|
||||
setCurrentIterationIndex(currentIterationIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||
<CardHeader className='pb-[12px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
|
||||
<Zap className='h-[16px] w-[16px]' />
|
||||
{formatted.blockName}
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Badge variant={formatted.status === 'success' ? 'default' : 'red'}>
|
||||
{formatted.blockType}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{formatted.status}</Badge>
|
||||
</div>
|
||||
|
||||
{iterationInfo.hasMultipleIterations && (
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<button
|
||||
onClick={goToPreviousIteration}
|
||||
disabled={currentIterationIndex === 0}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
type='button'
|
||||
>
|
||||
<ChevronLeft className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
<span className='px-[8px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
{iterationInfo.totalIterations !== undefined
|
||||
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
|
||||
: `${currentIterationIndex + 1}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextIteration}
|
||||
disabled={currentIterationIndex === totalIterations - 1}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
type='button'
|
||||
>
|
||||
<ChevronRight className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-[16px]'>
|
||||
<div className='grid grid-cols-2 gap-[12px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>{formatted.duration}</span>
|
||||
</div>
|
||||
|
||||
{formatted.cost && formatted.cost.total > 0 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>
|
||||
${formatted.cost.total.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formatted.tokens && formatted.tokens.total > 0 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>
|
||||
{formatted.tokens.total} tokens
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExpandableDataSection title='Input' data={formatted.input} />
|
||||
|
||||
<ExpandableDataSection title='Output' data={formatted.output} />
|
||||
|
||||
{formatted.cost && formatted.cost.total > 0 && (
|
||||
<div>
|
||||
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Cost Breakdown
|
||||
</h4>
|
||||
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Input:</span>
|
||||
<span>${formatted.cost.input.toFixed(5)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Output:</span>
|
||||
<span>${formatted.cost.output.toFixed(5)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
|
||||
<span>Total:</span>
|
||||
<span>${formatted.cost.total.toFixed(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formatted.tokens && formatted.tokens.total > 0 && (
|
||||
<div>
|
||||
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Token Usage
|
||||
</h4>
|
||||
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Input:</span>
|
||||
<span>{formatted.tokens.input}</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Output:</span>
|
||||
<span>{formatted.tokens.output}</span>
|
||||
</div>
|
||||
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
|
||||
<span>Total:</span>
|
||||
<span>{formatted.tokens.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface FrozenCanvasData {
|
||||
executionId: string
|
||||
workflowId: string
|
||||
workflowState: WorkflowState
|
||||
executionMetadata: {
|
||||
trigger: string
|
||||
startedAt: string
|
||||
endedAt?: string
|
||||
totalDurationMs?: number
|
||||
|
||||
cost: {
|
||||
total: number | null
|
||||
input: number | null
|
||||
output: number | null
|
||||
}
|
||||
totalTokens: number | null
|
||||
}
|
||||
}
|
||||
|
||||
interface FrozenCanvasProps {
|
||||
executionId: string
|
||||
traceSpans?: any[]
|
||||
className?: string
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
isModal?: boolean
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function FrozenCanvas({
|
||||
executionId,
|
||||
traceSpans,
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
isModal = false,
|
||||
isOpen = false,
|
||||
onClose,
|
||||
}: FrozenCanvasProps) {
|
||||
const [data, setData] = useState<FrozenCanvasData | null>(null)
|
||||
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
|
||||
|
||||
// Process traceSpans to create blockExecutions map
|
||||
useEffect(() => {
|
||||
if (traceSpans && Array.isArray(traceSpans)) {
|
||||
const blockExecutionMap: Record<string, any> = {}
|
||||
|
||||
logger.debug('Processing trace spans for frozen canvas:', { traceSpans })
|
||||
|
||||
// Recursively collect all spans with blockId from the trace spans tree
|
||||
const collectBlockSpans = (spans: any[]): any[] => {
|
||||
const blockSpans: any[] = []
|
||||
|
||||
for (const span of spans) {
|
||||
// If this span has a blockId, it's a block execution
|
||||
if (span.blockId) {
|
||||
blockSpans.push(span)
|
||||
}
|
||||
|
||||
// Recursively check children
|
||||
if (span.children && Array.isArray(span.children)) {
|
||||
blockSpans.push(...collectBlockSpans(span.children))
|
||||
}
|
||||
}
|
||||
|
||||
return blockSpans
|
||||
}
|
||||
|
||||
const allBlockSpans = collectBlockSpans(traceSpans)
|
||||
logger.debug('Collected all block spans:', allBlockSpans)
|
||||
|
||||
// Group spans by blockId
|
||||
const traceSpansByBlockId = allBlockSpans.reduce((acc: any, span: any) => {
|
||||
if (span.blockId) {
|
||||
if (!acc[span.blockId]) {
|
||||
acc[span.blockId] = []
|
||||
}
|
||||
acc[span.blockId].push(span)
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
logger.debug('Grouped trace spans by blockId:', traceSpansByBlockId)
|
||||
|
||||
for (const [blockId, spans] of Object.entries(traceSpansByBlockId)) {
|
||||
const spanArray = spans as any[]
|
||||
|
||||
const iterations = spanArray.map((span: any) => {
|
||||
// Extract error information from span output if status is error
|
||||
let errorMessage = null
|
||||
let errorStackTrace = null
|
||||
|
||||
if (span.status === 'error' && span.output) {
|
||||
// Error information can be in different formats in the output
|
||||
if (typeof span.output === 'string') {
|
||||
errorMessage = span.output
|
||||
} else if (span.output.error) {
|
||||
errorMessage = span.output.error
|
||||
errorStackTrace = span.output.stackTrace || span.output.stack
|
||||
} else if (span.output.message) {
|
||||
errorMessage = span.output.message
|
||||
errorStackTrace = span.output.stackTrace || span.output.stack
|
||||
} else {
|
||||
// Fallback: stringify the entire output for error cases
|
||||
errorMessage = JSON.stringify(span.output)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: span.id,
|
||||
blockId: span.blockId,
|
||||
blockName: span.name,
|
||||
blockType: span.type,
|
||||
status: span.status,
|
||||
startedAt: span.startTime,
|
||||
endedAt: span.endTime,
|
||||
durationMs: span.duration,
|
||||
inputData: span.input,
|
||||
outputData: span.output,
|
||||
errorMessage,
|
||||
errorStackTrace,
|
||||
cost: span.cost || {
|
||||
input: null,
|
||||
output: null,
|
||||
total: null,
|
||||
},
|
||||
tokens: span.tokens || {
|
||||
input: null,
|
||||
output: null,
|
||||
total: null,
|
||||
},
|
||||
modelUsed: span.model || null,
|
||||
metadata: {},
|
||||
}
|
||||
})
|
||||
|
||||
blockExecutionMap[blockId] = {
|
||||
iterations,
|
||||
currentIteration: 0,
|
||||
totalIterations: iterations.length,
|
||||
}
|
||||
}
|
||||
|
||||
setBlockExecutions(blockExecutionMap)
|
||||
}
|
||||
}, [traceSpans])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/logs/execution/${executionId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setData(result)
|
||||
logger.debug(`Loaded frozen canvas data for execution: ${executionId}`)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||
logger.error('Failed to fetch frozen canvas data:', err)
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [executionId])
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||
<span className='text-[13px]'>Loading frozen canvas...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-[16px] w-[16px]' />
|
||||
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='text-[13px] text-[var(--text-secondary)]'>No data available</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isMigratedLog = (data.workflowState as any)?._migrated === true
|
||||
if (isMigratedLog) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
|
||||
<AlertCircle className='h-[20px] w-[20px]' />
|
||||
<span className='font-medium text-[15px]'>Logged State Not Found</span>
|
||||
</div>
|
||||
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
|
||||
This log was migrated from the old logging system. The workflow state at execution time
|
||||
is not available.
|
||||
</div>
|
||||
<div className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Note: {(data.workflowState as any)?._note}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className={cn('frozen-canvas-mode h-full w-full', className)}
|
||||
>
|
||||
<WorkflowPreview
|
||||
workflowState={data.workflowState}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={(blockId) => {
|
||||
setPinnedBlockId(blockId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pinnedBlockId && (
|
||||
<PinnedLogs
|
||||
executionData={blockExecutions[pinnedBlockId] || null}
|
||||
blockId={pinnedBlockId}
|
||||
workflowState={data.workflowState}
|
||||
onClose={() => setPinnedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (isModal) {
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent size='xl' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
|
||||
<ModalBody className='min-h-0 flex-1'>
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='min-h-0 flex-1 overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return renderContent()
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { FrozenCanvas } from './frozen-canvas'
|
||||
@@ -5,7 +5,11 @@ import { ChevronUp, X } from 'lucide-react'
|
||||
import { Button, Eye } from '@/components/emcn'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
import {
|
||||
ExecutionSnapshot,
|
||||
FileCards,
|
||||
TraceSpans,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components'
|
||||
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
|
||||
import {
|
||||
formatDate,
|
||||
@@ -49,7 +53,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
hasNext = false,
|
||||
hasPrev = false,
|
||||
}: LogDetailsProps) {
|
||||
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
|
||||
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
|
||||
const { handleMouseDown } = useLogDetailsResize()
|
||||
@@ -266,7 +270,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
Workflow State
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsFrozenCanvasOpen(true)}
|
||||
onClick={() => setIsExecutionSnapshotOpen(true)}
|
||||
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--surface-4)]'
|
||||
>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -363,12 +367,12 @@ export const LogDetails = memo(function LogDetails({
|
||||
|
||||
{/* Frozen Canvas Modal */}
|
||||
{log?.executionId && (
|
||||
<FrozenCanvas
|
||||
<ExecutionSnapshot
|
||||
executionId={log.executionId}
|
||||
traceSpans={log.executionData?.traceSpans}
|
||||
isModal
|
||||
isOpen={isFrozenCanvasOpen}
|
||||
onClose={() => setIsFrozenCanvasOpen(false)}
|
||||
isOpen={isExecutionSnapshotOpen}
|
||||
onClose={() => setIsExecutionSnapshotOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface LogRowContextMenuProps {
|
||||
log: WorkflowLog | null
|
||||
onCopyExecutionId: () => void
|
||||
onOpenWorkflow: () => void
|
||||
onOpenPreview: () => void
|
||||
onToggleWorkflowFilter: () => void
|
||||
onClearAllFilters: () => void
|
||||
isFilteredByThisWorkflow: boolean
|
||||
@@ -36,6 +37,7 @@ export function LogRowContextMenu({
|
||||
log,
|
||||
onCopyExecutionId,
|
||||
onOpenWorkflow,
|
||||
onOpenPreview,
|
||||
onToggleWorkflowFilter,
|
||||
onClearAllFilters,
|
||||
isFilteredByThisWorkflow,
|
||||
@@ -78,6 +80,15 @@ export function LogRowContextMenu({
|
||||
>
|
||||
Open Workflow
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
disabled={!hasExecutionId}
|
||||
onClick={() => {
|
||||
onOpenPreview()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open Preview
|
||||
</PopoverItem>
|
||||
|
||||
{/* Filter actions */}
|
||||
<PopoverDivider />
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
||||
import {
|
||||
Dashboard,
|
||||
ExecutionSnapshot,
|
||||
LogDetails,
|
||||
LogRowContextMenu,
|
||||
LogsList,
|
||||
@@ -59,8 +60,7 @@ export default function Logs() {
|
||||
setWorkspaceId(workspaceId)
|
||||
}, [workspaceId, setWorkspaceId])
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
|
||||
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
|
||||
const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
@@ -90,6 +90,12 @@ export default function Logs() {
|
||||
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||||
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
|
||||
|
||||
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
|
||||
const activeLogQuery = useLogDetail(activeLogId ?? undefined)
|
||||
|
||||
const logFilters = useMemo(
|
||||
() => ({
|
||||
timeRange,
|
||||
@@ -129,19 +135,23 @@ export default function Logs() {
|
||||
refetchInterval: isLive ? 5000 : false,
|
||||
})
|
||||
|
||||
const logDetailQuery = useLogDetail(selectedLog?.id)
|
||||
|
||||
const mergedSelectedLog = useMemo(() => {
|
||||
if (!selectedLog) return null
|
||||
if (!logDetailQuery.data) return selectedLog
|
||||
return { ...selectedLog, ...logDetailQuery.data }
|
||||
}, [selectedLog, logDetailQuery.data])
|
||||
|
||||
const logs = useMemo(() => {
|
||||
if (!logsQuery.data?.pages) return []
|
||||
return logsQuery.data.pages.flatMap((page) => page.logs)
|
||||
}, [logsQuery.data?.pages])
|
||||
|
||||
const selectedLogIndex = useMemo(
|
||||
() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1),
|
||||
[logs, selectedLogId]
|
||||
)
|
||||
const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
if (!selectedLogFromList) return null
|
||||
if (!activeLogQuery.data || isPreviewOpen) return selectedLogFromList
|
||||
return { ...selectedLogFromList, ...activeLogQuery.data }
|
||||
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
|
||||
|
||||
useFolders(workspaceId)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -150,89 +160,40 @@ export default function Logs() {
|
||||
}
|
||||
}, [debouncedSearchQuery, setStoreSearchQuery])
|
||||
|
||||
const prevSelectedLogRef = useRef<WorkflowLog | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLog?.id || logs.length === 0) return
|
||||
|
||||
const updatedLog = logs.find((l) => l.id === selectedLog.id)
|
||||
if (!updatedLog) return
|
||||
|
||||
const prevLog = prevSelectedLogRef.current
|
||||
|
||||
const hasStatusChange =
|
||||
prevLog?.id === updatedLog.id &&
|
||||
(updatedLog.duration !== prevLog.duration || updatedLog.status !== prevLog.status)
|
||||
|
||||
if (updatedLog !== selectedLog) {
|
||||
setSelectedLog(updatedLog)
|
||||
prevSelectedLogRef.current = updatedLog
|
||||
}
|
||||
|
||||
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
|
||||
if (newIndex !== selectedLogIndex) {
|
||||
setSelectedLogIndex(newIndex)
|
||||
}
|
||||
|
||||
if (hasStatusChange) {
|
||||
logDetailQuery.refetch()
|
||||
}
|
||||
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || !selectedLog?.id) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
logDetailQuery.refetch()
|
||||
}, 5000)
|
||||
|
||||
if (!isLive || !selectedLogId) return
|
||||
const interval = setInterval(() => activeLogQuery.refetch(), 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isLive, selectedLog?.id, logDetailQuery])
|
||||
}, [isLive, selectedLogId, activeLogQuery])
|
||||
|
||||
const handleLogClick = useCallback(
|
||||
(log: WorkflowLog) => {
|
||||
if (selectedLog?.id === log.id && isSidebarOpen) {
|
||||
if (selectedLogId === log.id && isSidebarOpen) {
|
||||
setIsSidebarOpen(false)
|
||||
setSelectedLog(null)
|
||||
setSelectedLogIndex(-1)
|
||||
prevSelectedLogRef.current = null
|
||||
setSelectedLogId(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedLog(log)
|
||||
prevSelectedLogRef.current = log
|
||||
const index = logs.findIndex((l) => l.id === log.id)
|
||||
setSelectedLogIndex(index)
|
||||
setSelectedLogId(log.id)
|
||||
setIsSidebarOpen(true)
|
||||
},
|
||||
[selectedLog?.id, isSidebarOpen, logs]
|
||||
[selectedLogId, isSidebarOpen]
|
||||
)
|
||||
|
||||
const handleNavigateNext = useCallback(() => {
|
||||
if (selectedLogIndex < logs.length - 1) {
|
||||
const nextIndex = selectedLogIndex + 1
|
||||
setSelectedLogIndex(nextIndex)
|
||||
const nextLog = logs[nextIndex]
|
||||
setSelectedLog(nextLog)
|
||||
prevSelectedLogRef.current = nextLog
|
||||
setSelectedLogId(logs[selectedLogIndex + 1].id)
|
||||
}
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
const handleNavigatePrev = useCallback(() => {
|
||||
if (selectedLogIndex > 0) {
|
||||
const prevIndex = selectedLogIndex - 1
|
||||
setSelectedLogIndex(prevIndex)
|
||||
const prevLog = logs[prevIndex]
|
||||
setSelectedLog(prevLog)
|
||||
prevSelectedLogRef.current = prevLog
|
||||
setSelectedLogId(logs[selectedLogIndex - 1].id)
|
||||
}
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
const handleCloseSidebar = useCallback(() => {
|
||||
setIsSidebarOpen(false)
|
||||
setSelectedLog(null)
|
||||
setSelectedLogIndex(-1)
|
||||
prevSelectedLogRef.current = null
|
||||
setSelectedLogId(null)
|
||||
}, [])
|
||||
|
||||
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
|
||||
@@ -271,6 +232,13 @@ export default function Logs() {
|
||||
setSearchQuery('')
|
||||
}, [resetFilters, setSearchQuery])
|
||||
|
||||
const handleOpenPreview = useCallback(() => {
|
||||
if (contextMenuLog?.id) {
|
||||
setPreviewLogId(contextMenuLog.id)
|
||||
setIsPreviewOpen(true)
|
||||
}
|
||||
}, [contextMenuLog])
|
||||
|
||||
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
|
||||
const isFilteredByThisWorkflow = Boolean(
|
||||
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
|
||||
@@ -298,10 +266,10 @@ export default function Logs() {
|
||||
setIsVisuallyRefreshing(true)
|
||||
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
||||
logsQuery.refetch()
|
||||
if (selectedLog?.id) {
|
||||
logDetailQuery.refetch()
|
||||
if (selectedLogId) {
|
||||
activeLogQuery.refetch()
|
||||
}
|
||||
}, [logsQuery, logDetailQuery, selectedLog?.id])
|
||||
}, [logsQuery, activeLogQuery, selectedLogId])
|
||||
|
||||
const handleToggleLive = useCallback(() => {
|
||||
const newIsLive = !isLive
|
||||
@@ -393,9 +361,7 @@ export default function Logs() {
|
||||
|
||||
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
e.preventDefault()
|
||||
setSelectedLogIndex(0)
|
||||
setSelectedLog(logs[0])
|
||||
prevSelectedLogRef.current = logs[0]
|
||||
setSelectedLogId(logs[0].id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -409,7 +375,7 @@ export default function Logs() {
|
||||
handleNavigateNext()
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && selectedLog) {
|
||||
if (e.key === 'Enter' && selectedLogId) {
|
||||
e.preventDefault()
|
||||
setIsSidebarOpen(!isSidebarOpen)
|
||||
}
|
||||
@@ -417,7 +383,7 @@ export default function Logs() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [logs, selectedLogIndex, isSidebarOpen, selectedLog, handleNavigateNext, handleNavigatePrev])
|
||||
}, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
|
||||
|
||||
const isDashboardView = viewMode === 'dashboard'
|
||||
|
||||
@@ -509,7 +475,7 @@ export default function Logs() {
|
||||
) : (
|
||||
<LogsList
|
||||
logs={logs}
|
||||
selectedLogId={selectedLog?.id ?? null}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogClick={handleLogClick}
|
||||
onLogContextMenu={handleLogContextMenu}
|
||||
selectedRowRef={selectedRowRef}
|
||||
@@ -524,7 +490,7 @@ export default function Logs() {
|
||||
|
||||
{/* Log Details - rendered inside table container */}
|
||||
<LogDetails
|
||||
log={mergedSelectedLog}
|
||||
log={selectedLog}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={handleCloseSidebar}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
@@ -550,11 +516,25 @@ export default function Logs() {
|
||||
log={contextMenuLog}
|
||||
onCopyExecutionId={handleCopyExecutionId}
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
onOpenPreview={handleOpenPreview}
|
||||
onToggleWorkflowFilter={handleToggleWorkflowFilter}
|
||||
onClearAllFilters={handleClearAllFilters}
|
||||
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
|
||||
hasActiveFilters={filtersActive}
|
||||
/>
|
||||
|
||||
{isPreviewOpen && activeLogQuery.data?.executionId && (
|
||||
<ExecutionSnapshot
|
||||
executionId={activeLogQuery.data.executionId}
|
||||
traceSpans={activeLogQuery.data.executionData?.traceSpans}
|
||||
isModal
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false)
|
||||
setPreviewLogId(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate } from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
|
||||
interface CursorPoint {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
@@ -10,10 +11,15 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import {
|
||||
BlockDetailsSidebar,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { Versions } from './components'
|
||||
|
||||
@@ -49,48 +55,26 @@ export function GeneralDeploy({
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false)
|
||||
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
|
||||
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
|
||||
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
|
||||
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
|
||||
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
|
||||
|
||||
const versionCacheRef = useRef<Map<number, WorkflowState>>(new Map())
|
||||
const [, forceUpdate] = useState({})
|
||||
|
||||
const selectedVersionInfo = versions.find((v) => v.version === selectedVersion)
|
||||
const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote)
|
||||
const versionToLoadInfo = versions.find((v) => v.version === versionToLoad)
|
||||
|
||||
const cachedSelectedState =
|
||||
selectedVersion !== null ? versionCacheRef.current.get(selectedVersion) : null
|
||||
const { data: selectedVersionState } = useDeploymentVersionState(workflowId, selectedVersion)
|
||||
|
||||
const fetchSelectedVersionState = useCallback(
|
||||
async (version: number) => {
|
||||
if (!workflowId) return
|
||||
if (versionCacheRef.current.has(version)) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.deployedState) {
|
||||
versionCacheRef.current.set(version, data.deployedState)
|
||||
forceUpdate({})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching version state:', error)
|
||||
}
|
||||
},
|
||||
[workflowId]
|
||||
)
|
||||
const revertMutation = useRevertToVersion()
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVersion !== null) {
|
||||
fetchSelectedVersionState(selectedVersion)
|
||||
setPreviewMode('selected')
|
||||
} else {
|
||||
setPreviewMode('active')
|
||||
}
|
||||
}, [selectedVersion, fetchSelectedVersionState])
|
||||
}, [selectedVersion])
|
||||
|
||||
const handleSelectVersion = useCallback((version: number | null) => {
|
||||
setSelectedVersion(version)
|
||||
@@ -109,20 +93,12 @@ export function GeneralDeploy({
|
||||
const confirmLoadDeployment = async () => {
|
||||
if (!workflowId || versionToLoad === null) return
|
||||
|
||||
// Close modal immediately for snappy UX
|
||||
setShowLoadDialog(false)
|
||||
const version = versionToLoad
|
||||
setVersionToLoad(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load deployment')
|
||||
}
|
||||
|
||||
await revertMutation.mutateAsync({ workflowId, version })
|
||||
onLoadDeploymentComplete()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load deployment:', error)
|
||||
@@ -132,7 +108,6 @@ export function GeneralDeploy({
|
||||
const confirmPromoteToLive = async () => {
|
||||
if (versionToPromote === null) return
|
||||
|
||||
// Close modal immediately for snappy UX
|
||||
setShowPromoteDialog(false)
|
||||
const version = versionToPromote
|
||||
setVersionToPromote(null)
|
||||
@@ -145,15 +120,14 @@ export function GeneralDeploy({
|
||||
}
|
||||
|
||||
const workflowToShow = useMemo(() => {
|
||||
if (previewMode === 'selected' && cachedSelectedState) {
|
||||
return cachedSelectedState
|
||||
if (previewMode === 'selected' && selectedVersionState) {
|
||||
return selectedVersionState
|
||||
}
|
||||
return deployedState
|
||||
}, [previewMode, cachedSelectedState, deployedState])
|
||||
}, [previewMode, selectedVersionState, deployedState])
|
||||
|
||||
const showToggle = selectedVersion !== null && deployedState
|
||||
|
||||
// Only show skeleton on initial load when we have no deployed data
|
||||
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
|
||||
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
|
||||
|
||||
@@ -219,15 +193,31 @@ export function GeneralDeploy({
|
||||
}}
|
||||
>
|
||||
{workflowToShow ? (
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
/>
|
||||
<>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setShowExpandedPreview(true)}
|
||||
className='absolute top-[8px] right-[8px] z-10'
|
||||
>
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>Expand preview</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
|
||||
Deploy your workflow to see a preview
|
||||
@@ -304,6 +294,51 @@ export function GeneralDeploy({
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{workflowToShow && (
|
||||
<Modal
|
||||
open={showExpandedPreview}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setExpandedSelectedBlockId(null)
|
||||
}
|
||||
setShowExpandedPreview(open)
|
||||
}}
|
||||
>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>
|
||||
{previewMode === 'selected' && selectedVersionInfo
|
||||
? selectedVersionInfo.name || `v${selectedVersion}`
|
||||
: 'Live Workflow'}
|
||||
</ModalHeader>
|
||||
<ModalBody className='!p-0 min-h-0 flex-1'>
|
||||
<div className='flex h-full w-full overflow-hidden'>
|
||||
<div className='h-full flex-1'>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
onNodeClick={(blockId) => {
|
||||
setExpandedSelectedBlockId(
|
||||
expandedSelectedBlockId === blockId ? null : blockId
|
||||
)
|
||||
}}
|
||||
cursorStyle='pointer'
|
||||
/>
|
||||
</div>
|
||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
||||
onClose={() => setExpandedSelectedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import {
|
||||
useCreateTemplate,
|
||||
useDeleteTemplate,
|
||||
|
||||
@@ -332,7 +332,10 @@ export function LongInput({
|
||||
/>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm'
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm',
|
||||
(isPreview || disabled) && 'opacity-50'
|
||||
)}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
|
||||
@@ -374,7 +374,8 @@ export function ShortInput({
|
||||
ref={overlayRef}
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
showCopyButton ? 'pr-14' : 'pr-3'
|
||||
showCopyButton ? 'pr-14' : 'pr-3',
|
||||
(isPreview || disabled) && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className='min-w-fit whitespace-pre'>{formattedText}</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Slider } from '@/components/emcn/components/slider/slider'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
|
||||
interface SliderInputProps {
|
||||
@@ -58,15 +59,17 @@ export function SliderInput({
|
||||
|
||||
const percentage = ((normalizedValue - min) / (max - min)) * 100
|
||||
|
||||
const isDisabled = isPreview || disabled
|
||||
|
||||
return (
|
||||
<div className='relative pt-2 pb-[22px]'>
|
||||
<div className={cn('relative pt-2 pb-[22px]', isDisabled && 'opacity-50')}>
|
||||
<Slider
|
||||
value={[normalizedValue]}
|
||||
min={min}
|
||||
max={max}
|
||||
step={integer ? 1 : step}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={isPreview || disabled}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<div
|
||||
className='absolute top-6 text-muted-foreground text-sm'
|
||||
|
||||
@@ -949,7 +949,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
{!data.isPreview && (
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
|
||||
{shouldShowDefaultHandles && <Connections blockId={id} />}
|
||||
|
||||
|
||||
@@ -55,9 +55,11 @@ const WorkflowEdgeComponent = ({
|
||||
|
||||
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
|
||||
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
|
||||
const edgeRunStatus = lastRunEdges.get(id)
|
||||
const previewExecutionStatus = (
|
||||
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
|
||||
)?.executionStatus
|
||||
const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id)
|
||||
|
||||
// Memoize diff status calculation to avoid recomputing on every render
|
||||
const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
|
||||
if (data?.isDeleted) return 'deleted'
|
||||
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
|
||||
@@ -84,21 +86,39 @@ const WorkflowEdgeComponent = ({
|
||||
targetHandle,
|
||||
])
|
||||
|
||||
// Memoize edge style to prevent object recreation
|
||||
const edgeStyle = useMemo(() => {
|
||||
let color = 'var(--workflow-edge)'
|
||||
if (edgeDiffStatus === 'deleted') color = 'var(--text-error)'
|
||||
else if (isErrorEdge) color = 'var(--text-error)'
|
||||
else if (edgeDiffStatus === 'new') color = 'var(--brand-tertiary)'
|
||||
else if (edgeRunStatus === 'success') color = 'var(--border-success)'
|
||||
else if (edgeRunStatus === 'error') color = 'var(--text-error)'
|
||||
let opacity = 1
|
||||
|
||||
if (edgeDiffStatus === 'deleted') {
|
||||
color = 'var(--text-error)'
|
||||
opacity = 0.7
|
||||
} else if (isErrorEdge) {
|
||||
color = 'var(--text-error)'
|
||||
} else if (edgeDiffStatus === 'new') {
|
||||
color = 'var(--brand-tertiary)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
color = 'var(--border-success)'
|
||||
} else if (edgeRunStatus === 'error') {
|
||||
color = 'var(--text-error)'
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
opacity = 0.5
|
||||
}
|
||||
|
||||
return {
|
||||
...(style ?? {}),
|
||||
strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2,
|
||||
strokeWidth: edgeDiffStatus
|
||||
? 3
|
||||
: edgeRunStatus === 'success' || edgeRunStatus === 'error'
|
||||
? 2.5
|
||||
: isSelected
|
||||
? 2.5
|
||||
: 2,
|
||||
stroke: color,
|
||||
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
|
||||
opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1,
|
||||
opacity,
|
||||
}
|
||||
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
|
||||
|
||||
@@ -137,7 +157,6 @@ const WorkflowEdgeComponent = ({
|
||||
e.stopPropagation()
|
||||
|
||||
if (data?.onDelete) {
|
||||
// Pass this specific edge's ID to the delete function
|
||||
data.onDelete(id)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1337,6 +1337,11 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
@@ -1349,7 +1354,7 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
autoConnectEdge
|
||||
)
|
||||
|
||||
return
|
||||
@@ -1368,6 +1373,12 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = defaultTriggerName || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
enableTriggerMode,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
@@ -1376,7 +1387,7 @@ const WorkflowContent = React.memo(() => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge,
|
||||
enableTriggerMode
|
||||
)
|
||||
}
|
||||
@@ -1395,6 +1406,7 @@ const WorkflowContent = React.memo(() => {
|
||||
addBlock,
|
||||
effectivePermissions.canEdit,
|
||||
checkTriggerConstraints,
|
||||
tryCreateAutoConnectEdge,
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown as ChevronDownIcon, X } from 'lucide-react'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { Badge, Button, ChevronDown, Code } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
|
||||
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Evaluate whether a subblock's condition is met based on current values.
|
||||
*/
|
||||
function evaluateCondition(
|
||||
condition: SubBlockConfig['condition'],
|
||||
subBlockValues: Record<string, { value: unknown } | unknown>
|
||||
): boolean {
|
||||
if (!condition) return true
|
||||
|
||||
const actualCondition = typeof condition === 'function' ? condition() : condition
|
||||
|
||||
const fieldValueObj = subBlockValues[actualCondition.field]
|
||||
const fieldValue =
|
||||
fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
|
||||
? (fieldValueObj as { value: unknown }).value
|
||||
: fieldValueObj
|
||||
|
||||
const conditionValues = Array.isArray(actualCondition.value)
|
||||
? actualCondition.value
|
||||
: [actualCondition.value]
|
||||
|
||||
let isMatch = conditionValues.some((v) => v === fieldValue)
|
||||
|
||||
if (actualCondition.not) {
|
||||
isMatch = !isMatch
|
||||
}
|
||||
|
||||
if (actualCondition.and && isMatch) {
|
||||
const andFieldValueObj = subBlockValues[actualCondition.and.field]
|
||||
const andFieldValue =
|
||||
andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
|
||||
? (andFieldValueObj as { value: unknown }).value
|
||||
: andFieldValueObj
|
||||
|
||||
const andConditionValues = Array.isArray(actualCondition.and.value)
|
||||
? actualCondition.and.value
|
||||
: [actualCondition.and.value]
|
||||
|
||||
let andMatch = andConditionValues.some((v) => v === andFieldValue)
|
||||
|
||||
if (actualCondition.and.not) {
|
||||
andMatch = !andMatch
|
||||
}
|
||||
|
||||
isMatch = isMatch && andMatch
|
||||
}
|
||||
|
||||
return isMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for display as JSON string
|
||||
*/
|
||||
function formatValueAsJson(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
interface ResolvedConnection {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
fields: Array<{ path: string; value: string; tag: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all variable references from nested subblock values
|
||||
*/
|
||||
function extractAllReferencesFromSubBlocks(subBlockValues: Record<string, unknown>): string[] {
|
||||
const refs = new Set<string>()
|
||||
|
||||
const processValue = (value: unknown) => {
|
||||
if (typeof value === 'string') {
|
||||
const extracted = extractReferencePrefixes(value)
|
||||
extracted.forEach((ref) => refs.add(ref.raw))
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(processValue)
|
||||
} else if (value && typeof value === 'object') {
|
||||
if ('value' in value) {
|
||||
processValue((value as { value: unknown }).value)
|
||||
} else {
|
||||
Object.values(value).forEach(processValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(subBlockValues).forEach(processValue)
|
||||
return Array.from(refs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for inline display (single line, truncated)
|
||||
*/
|
||||
function formatInlineValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'null'
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
interface ExecutionDataSectionProps {
|
||||
title: string
|
||||
data: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible section for execution data (input/output)
|
||||
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
|
||||
*/
|
||||
function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const jsonString = useMemo(() => {
|
||||
if (!data) return ''
|
||||
return formatValueAsJson(data)
|
||||
}, [data])
|
||||
|
||||
const isEmpty = jsonString === '—' || jsonString === ''
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
||||
<div
|
||||
className='group flex cursor-pointer items-center justify-between'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${title.toLowerCase()}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-[12px] transition-colors',
|
||||
isError
|
||||
? 'text-[var(--text-error)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
style={{
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>No data</span>
|
||||
</div>
|
||||
) : (
|
||||
<Code.Viewer
|
||||
code={jsonString}
|
||||
language='json'
|
||||
className='!bg-[var(--surface-3)] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
|
||||
wrapText
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Section showing resolved variable references - styled like the connections section in editor
|
||||
*/
|
||||
function ResolvedConnectionsSection({ connections }: { connections: ResolvedConnection[] }) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
|
||||
}, [connections])
|
||||
|
||||
if (connections.length === 0) return null
|
||||
|
||||
const toggleBlock = (blockId: string) => {
|
||||
setExpandedBlocks((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(blockId)) {
|
||||
next.delete(blockId)
|
||||
} else {
|
||||
next.add(blockId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-shrink-0 flex-col border-[var(--border)] border-t'>
|
||||
{/* Header with Chevron */}
|
||||
<div
|
||||
className='flex flex-shrink-0 cursor-pointer items-center gap-[8px] px-[10px] pt-[5px] pb-[5px]'
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={isCollapsed ? 'Expand connections' : 'Collapse connections'}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn('h-[14px] w-[14px] transition-transform', !isCollapsed && 'rotate-180')}
|
||||
/>
|
||||
<div className='font-medium text-[13px] text-[var(--text-primary)]'>Connections</div>
|
||||
</div>
|
||||
|
||||
{/* Content - styled like ConnectionBlocks */}
|
||||
{!isCollapsed && (
|
||||
<div className='space-y-[2px] px-[6px] pb-[8px]'>
|
||||
{connections.map((connection) => {
|
||||
const blockConfig = getBlock(connection.blockType)
|
||||
const Icon = blockConfig?.icon
|
||||
const bgColor = blockConfig?.bgColor || '#6B7280'
|
||||
const isExpanded = expandedBlocks.has(connection.blockId)
|
||||
const hasFields = connection.fields.length > 0
|
||||
|
||||
return (
|
||||
<div key={connection.blockId} className='mb-[2px] last:mb-0'>
|
||||
{/* Block header - styled like ConnectionItem */}
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
|
||||
hasFields && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => hasFields && toggleBlock(connection.blockId)}
|
||||
>
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
'text-white transition-transform duration-200',
|
||||
hasFields && 'group-hover:scale-110',
|
||||
'!h-[9px] !w-[9px]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{connection.blockName}
|
||||
</span>
|
||||
{hasFields && (
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
|
||||
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fields - styled like FieldItem but showing resolved values */}
|
||||
{isExpanded && hasFields && (
|
||||
<div className='relative mt-[2px] ml-[12px] space-y-[2px] pl-[10px]'>
|
||||
<div className='pointer-events-none absolute top-[4px] bottom-[4px] left-0 w-px bg-[var(--border)]' />
|
||||
{connection.fields.map((field) => (
|
||||
<div
|
||||
key={field.tag}
|
||||
className='group flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]'
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex-shrink-0 font-medium',
|
||||
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{field.path}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-tertiary)]'>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon component for rendering block icons
|
||||
*/
|
||||
function IconComponent({
|
||||
icon: Icon,
|
||||
className,
|
||||
}: {
|
||||
icon: BlockIcon | undefined
|
||||
className?: string
|
||||
}) {
|
||||
if (!Icon) return null
|
||||
return <Icon className={className} />
|
||||
}
|
||||
|
||||
interface ExecutionData {
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
status?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface BlockDetailsSidebarProps {
|
||||
block: BlockState
|
||||
executionData?: ExecutionData
|
||||
/** All block execution data for resolving variable references */
|
||||
allBlockExecutions?: Record<string, ExecutionData>
|
||||
/** All workflow blocks for mapping block names to IDs */
|
||||
workflowBlocks?: Record<string, BlockState>
|
||||
/** When true, shows "Not Executed" badge if no executionData is provided */
|
||||
isExecutionMode?: boolean
|
||||
/** Optional close handler - if not provided, no close button is shown */
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Readonly sidebar panel showing block configuration using SubBlock components.
|
||||
*/
|
||||
function BlockDetailsSidebarContent({
|
||||
block,
|
||||
executionData,
|
||||
allBlockExecutions,
|
||||
workflowBlocks,
|
||||
isExecutionMode = false,
|
||||
onClose,
|
||||
}: BlockDetailsSidebarProps) {
|
||||
const blockConfig = getBlock(block.type) as BlockConfig | undefined
|
||||
const subBlockValues = block.subBlocks || {}
|
||||
|
||||
const blockNameToId = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
if (workflowBlocks) {
|
||||
for (const [blockId, blockData] of Object.entries(workflowBlocks)) {
|
||||
if (blockData.name) {
|
||||
map.set(normalizeName(blockData.name), blockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [workflowBlocks])
|
||||
|
||||
const resolveReference = useMemo(() => {
|
||||
return (reference: string): unknown => {
|
||||
if (!allBlockExecutions || !workflowBlocks) return undefined
|
||||
if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
|
||||
|
||||
const inner = reference.slice(1, -1) // Remove < and >
|
||||
const parts = inner.split('.')
|
||||
if (parts.length < 1) return undefined
|
||||
|
||||
const [blockName, ...pathParts] = parts
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const blockId = blockNameToId.get(normalizedBlockName)
|
||||
if (!blockId) return undefined
|
||||
|
||||
const blockExecution = allBlockExecutions[blockId]
|
||||
if (!blockExecution?.output) return undefined
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
return blockExecution.output
|
||||
}
|
||||
|
||||
return navigatePath(blockExecution.output, pathParts)
|
||||
}
|
||||
}, [allBlockExecutions, workflowBlocks, blockNameToId])
|
||||
|
||||
// Group resolved variables by source block for display
|
||||
const resolvedConnections = useMemo((): ResolvedConnection[] => {
|
||||
if (!allBlockExecutions || !workflowBlocks) return []
|
||||
|
||||
const allRefs = extractAllReferencesFromSubBlocks(subBlockValues)
|
||||
const seen = new Set<string>()
|
||||
const blockMap = new Map<string, ResolvedConnection>()
|
||||
|
||||
for (const ref of allRefs) {
|
||||
if (seen.has(ref)) continue
|
||||
|
||||
// Parse reference: <blockName.path.to.value>
|
||||
const inner = ref.slice(1, -1)
|
||||
const parts = inner.split('.')
|
||||
if (parts.length < 1) continue
|
||||
|
||||
const [blockName, ...pathParts] = parts
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
const blockId = blockNameToId.get(normalizedBlockName)
|
||||
if (!blockId) continue
|
||||
|
||||
const sourceBlock = workflowBlocks[blockId]
|
||||
if (!sourceBlock) continue
|
||||
|
||||
const resolvedValue = resolveReference(ref)
|
||||
if (resolvedValue === undefined) continue
|
||||
|
||||
seen.add(ref)
|
||||
|
||||
// Get or create block entry
|
||||
if (!blockMap.has(blockId)) {
|
||||
blockMap.set(blockId, {
|
||||
blockId,
|
||||
blockName: sourceBlock.name || blockName,
|
||||
blockType: sourceBlock.type,
|
||||
fields: [],
|
||||
})
|
||||
}
|
||||
|
||||
const connection = blockMap.get(blockId)!
|
||||
connection.fields.push({
|
||||
path: pathParts.join('.') || 'output',
|
||||
value: formatInlineValue(resolvedValue),
|
||||
tag: ref,
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(blockMap.values())
|
||||
}, [subBlockValues, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference])
|
||||
|
||||
if (!blockConfig) {
|
||||
return (
|
||||
<div className='flex h-full w-80 flex-col overflow-hidden rounded-r-[8px] border-[var(--border)] border-l bg-[var(--surface-1)]'>
|
||||
<div className='flex items-center gap-[8px] bg-[var(--surface-4)] px-[12px] py-[8px]'>
|
||||
<div className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px] bg-[var(--surface-3)]' />
|
||||
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{block.name || 'Unknown Block'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='p-[12px]'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>Block configuration not found.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden || subBlock.hideFromPreview) return false
|
||||
if (subBlock.mode === 'trigger') return false
|
||||
if (subBlock.condition) {
|
||||
return evaluateCondition(subBlock.condition, subBlockValues)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const statusVariant =
|
||||
executionData?.status === 'error'
|
||||
? 'red'
|
||||
: executionData?.status === 'success'
|
||||
? 'green'
|
||||
: 'gray'
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-80 flex-col overflow-hidden rounded-r-[8px] border-[var(--border)] border-l bg-[var(--surface-1)]'>
|
||||
{/* Header - styled like editor */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px] bg-[var(--surface-4)] px-[12px] py-[8px]'>
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ backgroundColor: blockConfig.bgColor }}
|
||||
>
|
||||
<IconComponent
|
||||
icon={blockConfig.icon}
|
||||
className='h-[12px] w-[12px] text-[var(--white)]'
|
||||
/>
|
||||
</div>
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{block.name || blockConfig.name}
|
||||
</span>
|
||||
{block.enabled === false && (
|
||||
<Badge variant='red' size='sm'>
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button variant='ghost' className='!p-[4px] flex-shrink-0' onClick={onClose}>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
|
||||
{isExecutionMode && !executionData && (
|
||||
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Badge variant='gray-secondary' size='sm' dot>
|
||||
Not Executed
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Input/Output (if provided) */}
|
||||
{executionData &&
|
||||
(executionData.input !== undefined || executionData.output !== undefined) ? (
|
||||
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
|
||||
{/* Execution Status & Duration Header */}
|
||||
{(executionData.status || executionData.durationMs !== undefined) && (
|
||||
<div className='flex items-center justify-between'>
|
||||
{executionData.status && (
|
||||
<Badge variant={statusVariant} size='sm' dot>
|
||||
<span className='capitalize'>{executionData.status}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{executionData.durationMs !== undefined && (
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatDuration(executionData.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider between Status/Duration and Input/Output */}
|
||||
{(executionData.status || executionData.durationMs !== undefined) &&
|
||||
(executionData.input !== undefined || executionData.output !== undefined) && (
|
||||
<div className='border-[var(--border)] border-t border-dashed' />
|
||||
)}
|
||||
|
||||
{/* Input Section */}
|
||||
{executionData.input !== undefined && (
|
||||
<ExecutionDataSection title='Input' data={executionData.input} />
|
||||
)}
|
||||
|
||||
{/* Divider between Input and Output */}
|
||||
{executionData.input !== undefined && executionData.output !== undefined && (
|
||||
<div className='border-[var(--border)] border-t border-dashed' />
|
||||
)}
|
||||
|
||||
{/* Output Section */}
|
||||
{executionData.output !== undefined && (
|
||||
<ExecutionDataSection
|
||||
title={executionData.status === 'error' ? 'Error' : 'Output'}
|
||||
data={executionData.output}
|
||||
isError={executionData.status === 'error'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Subblock Values - Using SubBlock components in preview mode */}
|
||||
<div className='readonly-preview px-[8px] py-[8px]'>
|
||||
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
|
||||
<style>{`
|
||||
.readonly-preview,
|
||||
.readonly-preview * {
|
||||
cursor: default !important;
|
||||
}
|
||||
.readonly-preview [disabled],
|
||||
.readonly-preview [data-disabled],
|
||||
.readonly-preview input,
|
||||
.readonly-preview textarea,
|
||||
.readonly-preview [role="combobox"],
|
||||
.readonly-preview [role="slider"],
|
||||
.readonly-preview [role="switch"],
|
||||
.readonly-preview [role="checkbox"] {
|
||||
opacity: 1 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
.readonly-preview .opacity-50 {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
{visibleSubBlocks.length > 0 ? (
|
||||
<div className='flex flex-col'>
|
||||
{visibleSubBlocks.map((subBlockConfig, index) => (
|
||||
<div key={subBlockConfig.id} className='subblock-row'>
|
||||
<SubBlock
|
||||
blockId={block.id}
|
||||
config={subBlockConfig}
|
||||
isPreview={true}
|
||||
subBlockValues={subBlockValues}
|
||||
disabled={true}
|
||||
/>
|
||||
{index < visibleSubBlocks.length - 1 && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='py-[16px] text-center'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
No configurable fields for this block.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolved Variables Section - Pinned at bottom, outside scrollable area */}
|
||||
{resolvedConnections.length > 0 && (
|
||||
<ResolvedConnectionsSection connections={resolvedConnections} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
|
||||
*/
|
||||
export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<BlockDetailsSidebarContent {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { getBlock } from '@/blocks'
|
||||
|
||||
interface WorkflowPreviewBlockData {
|
||||
type: string
|
||||
@@ -29,10 +29,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
}
|
||||
|
||||
const IconComponent = blockConfig.icon
|
||||
// Hide input handle for triggers, starters, or blocks in trigger mode
|
||||
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
|
||||
// Get visible subblocks from config (no fetching, just config structure)
|
||||
const visibleSubBlocks = useMemo(() => {
|
||||
if (!blockConfig.subBlocks) return []
|
||||
|
||||
@@ -48,7 +46,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
const hasSubBlocks = visibleSubBlocks.length > 0
|
||||
const showErrorRow = !isStarterOrTrigger
|
||||
|
||||
// Handle styles based on orientation
|
||||
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
|
||||
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
|
||||
|
||||
@@ -26,11 +26,9 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
|
||||
|
||||
// Handle IDs matching the actual subflow component
|
||||
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
|
||||
|
||||
// Handle styles matching the workflow-block component
|
||||
const leftHandleClass =
|
||||
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
|
||||
const rightHandleClass =
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BlockDetailsSidebar } from './components/block-details-sidebar'
|
||||
export { WorkflowPreview } from './preview'
|
||||
@@ -18,13 +18,16 @@ import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block'
|
||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow'
|
||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowPreview')
|
||||
|
||||
/** Execution status for edges/nodes in the preview */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
interface WorkflowPreviewProps {
|
||||
workflowState: WorkflowState
|
||||
showSubBlocks?: boolean
|
||||
@@ -40,6 +43,8 @@ interface WorkflowPreviewProps {
|
||||
lightweight?: boolean
|
||||
/** Cursor style to show when hovering the canvas */
|
||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
||||
executedBlocks?: Record<string, { status: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,10 +110,9 @@ export function WorkflowPreview({
|
||||
onNodeClick,
|
||||
lightweight = false,
|
||||
cursorStyle = 'grab',
|
||||
executedBlocks,
|
||||
}: WorkflowPreviewProps) {
|
||||
// Use lightweight node types for better performance in template cards
|
||||
const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
|
||||
// Check if the workflow state is valid
|
||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||
|
||||
const blocksStructure = useMemo(() => {
|
||||
@@ -178,9 +182,7 @@ export function WorkflowPreview({
|
||||
|
||||
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
||||
|
||||
// Lightweight mode: create minimal node data for performance
|
||||
if (lightweight) {
|
||||
// Handle loops and parallels as subflow nodes
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
@@ -197,7 +199,6 @@ export function WorkflowPreview({
|
||||
return
|
||||
}
|
||||
|
||||
// Regular blocks
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'workflowBlock',
|
||||
@@ -214,10 +215,9 @@ export function WorkflowPreview({
|
||||
return
|
||||
}
|
||||
|
||||
// Full mode: create detailed node data for interactive previews
|
||||
if (block.type === 'loop') {
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
position: absolutePosition,
|
||||
parentId: block.data?.parentId,
|
||||
@@ -238,7 +238,7 @@ export function WorkflowPreview({
|
||||
|
||||
if (block.type === 'parallel') {
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
position: absolutePosition,
|
||||
parentId: block.data?.parentId,
|
||||
@@ -265,11 +265,31 @@ export function WorkflowPreview({
|
||||
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
|
||||
let executionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
const blockExecution = executedBlocks[blockId]
|
||||
if (blockExecution) {
|
||||
if (blockExecution.status === 'error') {
|
||||
executionStatus = 'error'
|
||||
} else if (blockExecution.status === 'success') {
|
||||
executionStatus = 'success'
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
}
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: nodeType,
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
className:
|
||||
executionStatus && executionStatus !== 'not-executed'
|
||||
? `execution-${executionStatus}`
|
||||
: undefined,
|
||||
data: {
|
||||
type: block.type,
|
||||
config: blockConfig,
|
||||
@@ -278,43 +298,9 @@ export function WorkflowPreview({
|
||||
canEdit: false,
|
||||
isPreview: true,
|
||||
subBlockValues: block.subBlocks ?? {},
|
||||
executionStatus,
|
||||
},
|
||||
})
|
||||
|
||||
if (block.type === 'loop') {
|
||||
const childBlocks = Object.entries(workflowState.blocks || {}).filter(
|
||||
([_, childBlock]) => childBlock.data?.parentId === blockId
|
||||
)
|
||||
|
||||
childBlocks.forEach(([childId, childBlock]) => {
|
||||
const childConfig = getBlock(childBlock.type)
|
||||
|
||||
if (childConfig) {
|
||||
const childNodeType = childBlock.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
|
||||
nodeArray.push({
|
||||
id: childId,
|
||||
type: childNodeType,
|
||||
position: {
|
||||
x: block.position.x + 50,
|
||||
y: block.position.y + (childBlock.position?.y || 100),
|
||||
},
|
||||
data: {
|
||||
type: childBlock.type,
|
||||
config: childConfig,
|
||||
name: childBlock.name,
|
||||
blockState: childBlock,
|
||||
showSubBlocks,
|
||||
isChild: true,
|
||||
parentId: blockId,
|
||||
canEdit: false,
|
||||
isPreview: true,
|
||||
},
|
||||
draggable: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
@@ -326,21 +312,42 @@ export function WorkflowPreview({
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
lightweight,
|
||||
executedBlocks,
|
||||
])
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
return (workflowState.edges || []).map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
}))
|
||||
}, [edgesStructure, workflowState.edges, isValidWorkflowState])
|
||||
return (workflowState.edges || []).map((edge) => {
|
||||
let executionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
const sourceExecuted = executedBlocks[edge.source]
|
||||
const targetExecuted = executedBlocks[edge.target]
|
||||
|
||||
if (sourceExecuted && targetExecuted) {
|
||||
if (targetExecuted.status === 'error') {
|
||||
executionStatus = 'error'
|
||||
} else if (sourceExecuted.status === 'success' && targetExecuted.status === 'success') {
|
||||
executionStatus = 'success'
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: executionStatus ? { executionStatus } : undefined,
|
||||
}
|
||||
})
|
||||
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
|
||||
|
||||
// Handle migrated logs that don't have complete workflow state
|
||||
if (!isValidWorkflowState) {
|
||||
return (
|
||||
<div
|
||||
@@ -363,13 +370,19 @@ export function WorkflowPreview({
|
||||
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
||||
className={cn('preview-mode', className)}
|
||||
>
|
||||
{cursorStyle && (
|
||||
<style>{`
|
||||
.preview-mode .react-flow__pane {
|
||||
cursor: ${cursorStyle} !important;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<style>{`
|
||||
${cursorStyle ? `.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }` : ''}
|
||||
|
||||
/* Execution status styling for nodes */
|
||||
.preview-mode .react-flow__node.execution-success {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 4px var(--border-success);
|
||||
}
|
||||
.preview-mode .react-flow__node.execution-error {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 4px var(--text-error);
|
||||
}
|
||||
`}</style>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Crown, Eye, EyeOff } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -81,7 +81,9 @@ export function BYOK() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
|
||||
const { data: keys = [], isLoading } = useBYOKKeys(workspaceId)
|
||||
const { data, isLoading } = useBYOKKeys(workspaceId)
|
||||
const keys = data?.keys ?? []
|
||||
const byokEnabled = data?.byokEnabled ?? true
|
||||
const upsertKey = useUpsertBYOKKey()
|
||||
const deleteKey = useDeleteBYOKKey()
|
||||
|
||||
@@ -96,6 +98,31 @@ export function BYOK() {
|
||||
return keys.find((k) => k.providerId === providerId)
|
||||
}
|
||||
|
||||
// Show enterprise-only gate if BYOK is not enabled
|
||||
if (!isLoading && !byokEnabled) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[16px] py-[32px]'>
|
||||
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-6)]'>
|
||||
<Crown className='h-[24px] w-[24px] text-[var(--amber-9)]' />
|
||||
</div>
|
||||
<div className='flex flex-col items-center gap-[8px] text-center'>
|
||||
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>Enterprise Feature</h3>
|
||||
<p className='max-w-[320px] text-[13px] text-[var(--text-secondary)]'>
|
||||
Bring Your Own Key (BYOK) is available exclusively on the Enterprise plan. Upgrade to
|
||||
use your own API keys and eliminate the 2x cost multiplier.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
onClick={() => window.open('https://sim.ai/enterprise', '_blank')}
|
||||
>
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingProvider || !apiKeyInput.trim()) return
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components'
|
||||
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
|
||||
import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
|
||||
import {
|
||||
type CredentialSet,
|
||||
useAcceptCredentialSetInvitation,
|
||||
|
||||
@@ -480,7 +480,7 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='auto-connect'>Auto-connect on drag</Label>
|
||||
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
|
||||
<Switch
|
||||
id='auto-connect'
|
||||
checked={settings?.autoConnect ?? true}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import type { Invitation, Member, Organization } from '@/lib/workspaces/organization'
|
||||
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
|
||||
import {
|
||||
useCancelInvitation,
|
||||
useOrganizationMembers,
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
@@ -86,8 +87,8 @@ type NavigationItem = {
|
||||
hideWhenBillingDisabled?: boolean
|
||||
requiresTeam?: boolean
|
||||
requiresEnterprise?: boolean
|
||||
requiresOwner?: boolean
|
||||
requiresHosted?: boolean
|
||||
selfHostedOverride?: boolean
|
||||
}
|
||||
|
||||
const sectionConfig: { key: NavigationSection; title: string }[] = [
|
||||
@@ -113,6 +114,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
icon: Users,
|
||||
section: 'subscription',
|
||||
hideWhenBillingDisabled: true,
|
||||
requiresHosted: true,
|
||||
requiresTeam: true,
|
||||
},
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
@@ -123,7 +125,8 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'Email Polling',
|
||||
icon: Mail,
|
||||
section: 'system',
|
||||
requiresTeam: true,
|
||||
requiresHosted: true,
|
||||
selfHostedOverride: isCredentialSetsEnabled,
|
||||
},
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
@@ -134,6 +137,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
icon: KeySquare,
|
||||
section: 'system',
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
},
|
||||
{
|
||||
id: 'copilot',
|
||||
@@ -148,9 +152,9 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'Single Sign-On',
|
||||
icon: LogIn,
|
||||
section: 'system',
|
||||
requiresTeam: true,
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
requiresOwner: true,
|
||||
selfHostedOverride: isSSOEnabled,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -173,8 +177,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
const isOwner = userRole === 'owner'
|
||||
const isAdmin = userRole === 'admin'
|
||||
const canManageSSO = isOwner || isAdmin
|
||||
const isOrgAdminOrOwner = isOwner || isAdmin
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
|
||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||
const hasOrganization = !!activeOrganization?.id
|
||||
|
||||
@@ -192,29 +197,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
// SSO has special logic that must be checked before requiresTeam
|
||||
if (item.id === 'sso') {
|
||||
if (isHosted) {
|
||||
return hasOrganization && hasEnterprisePlan && canManageSSO
|
||||
if (item.selfHostedOverride && !isHosted) {
|
||||
if (item.id === 'sso') {
|
||||
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
|
||||
return !hasProviders || isSSOProviderOwner === true
|
||||
}
|
||||
// For self-hosted, only show SSO tab if explicitly enabled via environment variable
|
||||
if (!isSSOEnabled) return false
|
||||
// Show tab if user is the SSO provider owner, or if no providers exist yet (to allow initial setup)
|
||||
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
|
||||
return !hasProviders || isSSOProviderOwner === true
|
||||
return true
|
||||
}
|
||||
|
||||
if (item.requiresTeam) {
|
||||
const isMember = userRole === 'member' || isAdmin
|
||||
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
|
||||
|
||||
if (isMember) return true
|
||||
if (isOwner && hasTeamPlan) return true
|
||||
|
||||
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresEnterprise && !hasEnterprisePlan) {
|
||||
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -222,24 +217,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresOwner && !isOwner) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [
|
||||
hasOrganization,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
canManageSSO,
|
||||
isOrgAdminOrOwner,
|
||||
isSSOProviderOwner,
|
||||
isSSOEnabled,
|
||||
ssoProvidersData?.providers?.length,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
userRole,
|
||||
subscriptionStatus.isTeam,
|
||||
subscriptionStatus.isEnterprise,
|
||||
])
|
||||
|
||||
// Memoized callbacks to prevent infinite loops in child components
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
|
||||
interface AvatarsProps {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './email-tag'
|
||||
export * from './permission-selector'
|
||||
export * from './permissions-table'
|
||||
export * from './permissions-table-skeleton'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,6 @@
|
||||
export { EmailTag } from './components/email-tag'
|
||||
export { PermissionSelector } from './components/permission-selector'
|
||||
export { PermissionsTable } from './components/permissions-table'
|
||||
export { PermissionsTableSkeleton } from './components/permissions-table-skeleton'
|
||||
export type { PermissionType, UserPermissions } from './components/types'
|
||||
export { InviteModal } from './invite-modal'
|
||||
@@ -18,9 +18,10 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/email-tag'
|
||||
import { PermissionsTable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table'
|
||||
import { API_ENDPOINTS } from '@/stores/constants'
|
||||
import type { PermissionType, UserPermissions } from './components'
|
||||
import { EmailTag, PermissionsTable } from './components'
|
||||
import type { PermissionType, UserPermissions } from './components/types'
|
||||
|
||||
const logger = createLogger('InviteModal')
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
|
||||
|
||||
const logger = createLogger('WorkspaceHeader')
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* User color palette matching terminal.tsx RUN_ID_COLORS
|
||||
* These colors are used consistently across cursors, avatars, and terminal run IDs
|
||||
*/
|
||||
export const USER_COLORS = [
|
||||
'#4ADE80', // Green
|
||||
'#F472B6', // Pink
|
||||
'#60C5FF', // Blue
|
||||
'#FF8533', // Orange
|
||||
'#C084FC', // Purple
|
||||
'#FCD34D', // Yellow
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Hash a user ID to generate a consistent numeric index
|
||||
*
|
||||
* @param userId - The user ID to hash
|
||||
* @returns A positive integer
|
||||
*/
|
||||
function hashUserId(userId: string): number {
|
||||
return Math.abs(Array.from(userId).reduce((acc, char) => acc + char.charCodeAt(0), 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a consistent color for a user based on their ID.
|
||||
* The same user will always get the same color across cursors, avatars, and terminal.
|
||||
*
|
||||
* @param userId - The unique user identifier
|
||||
* @returns A hex color string
|
||||
*/
|
||||
export function getUserColor(userId: string): string {
|
||||
const hash = hashUserId(userId)
|
||||
return USER_COLORS[hash % USER_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stable mapping of user IDs to color indices for a list of users.
|
||||
* Useful when you need to maintain consistent color assignments across renders.
|
||||
*
|
||||
* @param userIds - Array of user IDs to map
|
||||
* @returns Map of user ID to color index
|
||||
*/
|
||||
export function createUserColorMap(userIds: string[]): Map<string, number> {
|
||||
const colorMap = new Map<string, number>()
|
||||
let colorIndex = 0
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (!colorMap.has(userId)) {
|
||||
colorMap.set(userId, colorIndex++)
|
||||
}
|
||||
}
|
||||
|
||||
return colorMap
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* A2A Block (v0.2.6)
|
||||
*
|
||||
* Enables interaction with external A2A-compatible agents.
|
||||
* Supports sending messages, querying tasks, cancelling tasks, and discovering agents.
|
||||
*/
|
||||
|
||||
import { A2AIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface A2AResponse extends ToolResponse {
|
||||
output: {
|
||||
/** Response content from the agent (send_task) */
|
||||
content?: string
|
||||
/** Task ID */
|
||||
taskId?: string
|
||||
/** Context ID for conversation continuity */
|
||||
contextId?: string
|
||||
/** Task state */
|
||||
state?: string
|
||||
/** Structured output artifacts */
|
||||
artifacts?: Array<{
|
||||
name?: string
|
||||
description?: string
|
||||
parts: Array<{ type: string; text?: string; data?: unknown }>
|
||||
}>
|
||||
/** Full message history */
|
||||
history?: Array<{
|
||||
role: 'user' | 'agent'
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}>
|
||||
/** Whether cancellation was successful (cancel_task) */
|
||||
cancelled?: boolean
|
||||
/** Agent name (get_agent_card) */
|
||||
name?: string
|
||||
/** Agent description (get_agent_card) */
|
||||
description?: string
|
||||
/** Agent URL (get_agent_card) */
|
||||
url?: string
|
||||
/** Agent version (get_agent_card) */
|
||||
version?: string
|
||||
/** Agent capabilities (get_agent_card) */
|
||||
capabilities?: Record<string, boolean>
|
||||
/** Agent skills (get_agent_card) */
|
||||
skills?: Array<{ id: string; name: string; description?: string }>
|
||||
/** Agent authentication schemes (get_agent_card) */
|
||||
authentication?: { schemes: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
export const A2ABlock: BlockConfig<A2AResponse> = {
|
||||
type: 'a2a',
|
||||
name: 'A2A',
|
||||
description: 'Interact with external A2A-compatible agents',
|
||||
longDescription:
|
||||
'Use the A2A (Agent-to-Agent) protocol to interact with external AI agents. ' +
|
||||
'Send messages, query task status, cancel tasks, or discover agent capabilities. ' +
|
||||
'Compatible with any A2A-compliant agent including LangGraph, Google ADK, and other Sim Studio workflows.',
|
||||
docsLink: 'https://docs.sim.ai/blocks/a2a',
|
||||
category: 'tools',
|
||||
bgColor: '#4151B5',
|
||||
icon: A2AIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Send Message', id: 'send_task' },
|
||||
{ label: 'Get Task', id: 'get_task' },
|
||||
{ label: 'Cancel Task', id: 'cancel_task' },
|
||||
{ label: 'Get Agent Card', id: 'get_agent_card' },
|
||||
],
|
||||
defaultValue: 'send_task',
|
||||
},
|
||||
{
|
||||
id: 'agentUrl',
|
||||
title: 'Agent URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://api.example.com/a2a/serve/agent-id',
|
||||
required: true,
|
||||
description: 'The A2A endpoint URL',
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
title: 'Message',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter your message to the agent...',
|
||||
description: 'The message to send to the agent',
|
||||
condition: { field: 'operation', value: 'send_task' },
|
||||
required: { field: 'operation', value: 'send_task' },
|
||||
},
|
||||
{
|
||||
id: 'taskId',
|
||||
title: 'Task ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Task ID',
|
||||
description: 'Task ID to query, cancel, or continue',
|
||||
condition: { field: 'operation', value: ['send_task', 'get_task', 'cancel_task'] },
|
||||
required: { field: 'operation', value: ['get_task', 'cancel_task'] },
|
||||
},
|
||||
{
|
||||
id: 'contextId',
|
||||
title: 'Context ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Optional - for multi-turn conversations',
|
||||
description: 'Context ID for conversation continuity across tasks',
|
||||
condition: { field: 'operation', value: 'send_task' },
|
||||
},
|
||||
{
|
||||
id: 'historyLength',
|
||||
title: 'History Length',
|
||||
type: 'short-input',
|
||||
placeholder: 'Number of messages to include',
|
||||
description: 'Number of history messages to include in the response',
|
||||
condition: { field: 'operation', value: 'get_task' },
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'API key for the remote agent',
|
||||
description: 'Authentication key for the A2A agent',
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['a2a_send_task', 'a2a_get_task', 'a2a_cancel_task', 'a2a_get_agent_card'],
|
||||
config: {
|
||||
tool: (params: Record<string, unknown>) => {
|
||||
const operation = params.operation as string
|
||||
switch (operation) {
|
||||
case 'get_task':
|
||||
return 'a2a_get_task'
|
||||
case 'cancel_task':
|
||||
return 'a2a_cancel_task'
|
||||
case 'get_agent_card':
|
||||
return 'a2a_get_agent_card'
|
||||
default:
|
||||
return 'a2a_send_task'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
description: 'A2A operation to perform',
|
||||
},
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
description: 'A2A endpoint URL',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Message to send to the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID to query, cancel, or continue',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
historyLength: {
|
||||
type: 'number',
|
||||
description: 'Number of history messages to include',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
// Send task outputs
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The text response from the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for follow-up interactions',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state (completed, failed, etc.)',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Structured output artifacts from the agent',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Full message history of the conversation',
|
||||
},
|
||||
// Cancel task output
|
||||
cancelled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the task was successfully cancelled',
|
||||
},
|
||||
// Get agent card outputs
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Agent name',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Agent description',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Agent endpoint URL',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
description: 'Agent version',
|
||||
},
|
||||
capabilities: {
|
||||
type: 'json',
|
||||
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
|
||||
},
|
||||
skills: {
|
||||
type: 'array',
|
||||
description: 'Skills the agent can perform',
|
||||
},
|
||||
authentication: {
|
||||
type: 'json',
|
||||
description: 'Supported authentication schemes',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -77,7 +77,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
// Project Update Operations
|
||||
{ label: 'Create Project Update', id: 'linear_create_project_update' },
|
||||
{ label: 'List Project Updates', id: 'linear_list_project_updates' },
|
||||
{ label: 'Create Project Link', id: 'linear_create_project_link' },
|
||||
// Notification Operations
|
||||
{ label: 'List Notifications', id: 'linear_list_notifications' },
|
||||
{ label: 'Update Notification', id: 'linear_update_notification' },
|
||||
@@ -227,6 +226,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_update_project',
|
||||
'linear_archive_project',
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
],
|
||||
},
|
||||
@@ -239,6 +239,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_update_project',
|
||||
'linear_archive_project',
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_list_project_labels',
|
||||
],
|
||||
@@ -261,7 +262,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_create_project_link',
|
||||
],
|
||||
},
|
||||
condition: {
|
||||
@@ -275,7 +275,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_create_project_link',
|
||||
'linear_list_project_labels',
|
||||
],
|
||||
},
|
||||
@@ -625,7 +624,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_attachment', 'linear_create_project_link'],
|
||||
value: ['linear_create_attachment'],
|
||||
},
|
||||
},
|
||||
// Attachment title
|
||||
@@ -1221,6 +1220,36 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
value: ['linear_create_project_status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projectStatusType',
|
||||
title: 'Status Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Backlog', id: 'backlog' },
|
||||
{ label: 'Planned', id: 'planned' },
|
||||
{ label: 'Started', id: 'started' },
|
||||
{ label: 'Paused', id: 'paused' },
|
||||
{ label: 'Completed', id: 'completed' },
|
||||
{ label: 'Canceled', id: 'canceled' },
|
||||
],
|
||||
value: () => 'started',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_project_status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projectStatusPosition',
|
||||
title: 'Position',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter position (e.g. 0, 1, 2...)',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_project_status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projectStatusId',
|
||||
title: 'Status ID',
|
||||
@@ -1326,7 +1355,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
'linear_list_favorites',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_create_project_link',
|
||||
'linear_list_notifications',
|
||||
'linear_update_notification',
|
||||
'linear_create_customer',
|
||||
@@ -1772,17 +1800,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
projectId: effectiveProjectId,
|
||||
}
|
||||
|
||||
case 'linear_create_project_link':
|
||||
if (!effectiveProjectId || !params.url?.trim()) {
|
||||
throw new Error('Project ID and URL are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
projectId: effectiveProjectId,
|
||||
url: params.url.trim(),
|
||||
label: params.name,
|
||||
}
|
||||
|
||||
case 'linear_list_notifications':
|
||||
return baseParams
|
||||
|
||||
@@ -2033,22 +2050,22 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
}
|
||||
|
||||
case 'linear_add_label_to_project':
|
||||
if (!effectiveProjectId || !params.projectLabelId?.trim()) {
|
||||
if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
|
||||
throw new Error('Project ID and label ID are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
projectId: effectiveProjectId,
|
||||
projectId: params.projectIdForMilestone.trim(),
|
||||
labelId: params.projectLabelId.trim(),
|
||||
}
|
||||
|
||||
case 'linear_remove_label_from_project':
|
||||
if (!effectiveProjectId || !params.projectLabelId?.trim()) {
|
||||
if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
|
||||
throw new Error('Project ID and label ID are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
projectId: effectiveProjectId,
|
||||
projectId: params.projectIdForMilestone.trim(),
|
||||
labelId: params.projectLabelId.trim(),
|
||||
}
|
||||
|
||||
@@ -2097,13 +2114,20 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
|
||||
// Project Status Operations
|
||||
case 'linear_create_project_status':
|
||||
if (!params.projectStatusName?.trim() || !params.statusColor?.trim()) {
|
||||
throw new Error('Project status name and color are required.')
|
||||
if (
|
||||
!params.projectStatusName?.trim() ||
|
||||
!params.projectStatusType?.trim() ||
|
||||
!params.statusColor?.trim() ||
|
||||
!params.projectStatusPosition?.trim()
|
||||
) {
|
||||
throw new Error('Project status name, type, color, and position are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
name: params.projectStatusName.trim(),
|
||||
type: params.projectStatusType.trim(),
|
||||
color: params.statusColor.trim(),
|
||||
position: Number.parseFloat(params.projectStatusPosition.trim()),
|
||||
description: params.projectStatusDescription?.trim() || undefined,
|
||||
indefinite: params.projectStatusIndefinite === 'true',
|
||||
}
|
||||
@@ -2270,7 +2294,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
// Project update outputs
|
||||
update: { type: 'json', description: 'Project update data' },
|
||||
updates: { type: 'json', description: 'Project updates list' },
|
||||
link: { type: 'json', description: 'Project link data' },
|
||||
// Notification outputs
|
||||
notification: { type: 'json', description: 'Notification data' },
|
||||
notifications: { type: 'json', description: 'Notifications list' },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { A2ABlock } from '@/blocks/blocks/a2a'
|
||||
import { AgentBlock } from '@/blocks/blocks/agent'
|
||||
import { AhrefsBlock } from '@/blocks/blocks/ahrefs'
|
||||
import { AirtableBlock } from '@/blocks/blocks/airtable'
|
||||
@@ -148,7 +147,6 @@ import { SQSBlock } from './blocks/sqs'
|
||||
|
||||
// Registry of all available blocks, alphabetically sorted
|
||||
export const registry: Record<string, BlockConfig> = {
|
||||
a2a: A2ABlock,
|
||||
agent: AgentBlock,
|
||||
ahrefs: AhrefsBlock,
|
||||
airtable: AirtableBlock,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
* ```
|
||||
*/
|
||||
const checkboxVariants = cva(
|
||||
'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@@ -467,7 +467,12 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
||||
{...inputProps}
|
||||
/>
|
||||
{(overlayContent || SelectedIcon) && (
|
||||
<div className='pointer-events-none absolute top-0 right-[42px] bottom-0 left-0 flex items-center bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-0 right-[42px] bottom-0 left-0 flex items-center bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{overlayContent ? (
|
||||
overlayContent
|
||||
) : (
|
||||
@@ -505,6 +510,7 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
||||
className={cn(
|
||||
comboboxVariants({ variant, size }),
|
||||
'relative cursor-pointer items-center justify-between',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -844,6 +844,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
className={cn(
|
||||
datePickerVariants({ variant, size }),
|
||||
'relative cursor-pointer items-center justify-between',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={handleTriggerClick}
|
||||
|
||||
@@ -16,12 +16,13 @@ export interface SliderProps extends React.ComponentPropsWithoutRef<typeof Slide
|
||||
* ```
|
||||
*/
|
||||
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
({ className, disabled, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -12,10 +12,12 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, disabled, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-[17px] w-[30px] shrink-0 cursor-pointer items-center rounded-[20px] transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'peer inline-flex h-[17px] w-[30px] shrink-0 cursor-pointer items-center rounded-[20px] transition-colors focus-visible:outline-none',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||
'bg-[var(--border-1)] data-[state=checked]:bg-[var(--text-primary)]',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -4061,31 +4061,6 @@ export function McpIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function A2AIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 860 860' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<circle cx='544' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='154' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='706' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='316' cy='307' r='27' fill='currentColor' />
|
||||
<path
|
||||
d='M336.5 191.003H162C97.6588 191.003 45.5 243.162 45.5 307.503C45.5 371.844 97.6442 424.003 161.985 424.003C206.551 424.003 256.288 424.003 296.5 424.003C487.5 424.003 374 191.005 569 191.001C613.886 191 658.966 191 698.025 191C762.366 191.001 814.5 243.16 814.5 307.501C814.5 371.843 762.34 424.003 697.998 424.003H523.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='48'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
<path
|
||||
d='M256 510.002C270.359 510.002 282 521.643 282 536.002C282 550.361 270.359 562.002 256 562.002H148C133.641 562.002 122 550.361 122 536.002C122 521.643 133.641 510.002 148 510.002H256ZM712 510.002C726.359 510.002 738 521.643 738 536.002C738 550.361 726.359 562.002 712 562.002H360C345.641 562.002 334 550.361 334 536.002C334 521.643 345.641 510.002 360 510.002H712Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M444 628.002C458.359 628.002 470 639.643 470 654.002C470 668.361 458.359 680.002 444 680.002H100C85.6406 680.002 74 668.361 74 654.002C74 639.643 85.6406 628.002 100 628.002H444ZM548 628.002C562.359 628.002 574 639.643 574 654.002C574 668.361 562.359 680.002 548 680.002C533.641 680.002 522 668.361 522 654.002C522 639.643 533.641 628.002 548 628.002ZM760 628.002C774.359 628.002 786 639.643 786 654.002C786 668.361 774.359 680.002 760 680.002H652C637.641 680.002 626 668.361 626 654.002C626 639.643 637.641 628.002 652 628.002H760Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WordpressIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25.925 25.925'>
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* A2A Agents React Query Hooks
|
||||
*
|
||||
* Hooks for managing A2A agents in the UI.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { AgentAuthentication, AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
|
||||
/**
|
||||
* A2A Agent as returned from the API
|
||||
*/
|
||||
export interface A2AAgent {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
name: string
|
||||
description?: string
|
||||
version: string
|
||||
capabilities: AgentCapabilities
|
||||
skills: AgentSkill[]
|
||||
authentication: AgentAuthentication
|
||||
isPublished: boolean
|
||||
publishedAt?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
workflowName?: string
|
||||
workflowDescription?: string
|
||||
isDeployed?: boolean
|
||||
taskCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Query keys for A2A agents
|
||||
*/
|
||||
export const a2aAgentKeys = {
|
||||
all: ['a2a-agents'] as const,
|
||||
list: (workspaceId: string) => [...a2aAgentKeys.all, 'list', workspaceId] as const,
|
||||
detail: (agentId: string) => [...a2aAgentKeys.all, 'detail', agentId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch A2A agents for a workspace
|
||||
*/
|
||||
async function fetchA2AAgents(workspaceId: string): Promise<A2AAgent[]> {
|
||||
const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch A2A agents')
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.agents
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to list A2A agents for a workspace
|
||||
*/
|
||||
export function useA2AAgents(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: a2aAgentKeys.list(workspaceId),
|
||||
queryFn: () => fetchA2AAgents(workspaceId),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Card as returned from the agent detail endpoint
|
||||
*/
|
||||
export interface A2AAgentCard {
|
||||
name: string
|
||||
description?: string
|
||||
url: string
|
||||
version: string
|
||||
documentationUrl?: string
|
||||
provider?: {
|
||||
organization: string
|
||||
url?: string
|
||||
}
|
||||
capabilities: AgentCapabilities
|
||||
skills: AgentSkill[]
|
||||
authentication?: AgentAuthentication
|
||||
defaultInputModes?: string[]
|
||||
defaultOutputModes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single A2A agent card (discovery document)
|
||||
*/
|
||||
async function fetchA2AAgentCard(agentId: string): Promise<A2AAgentCard> {
|
||||
const response = await fetch(`/api/a2a/agents/${agentId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch A2A agent')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a single A2A agent card (discovery document)
|
||||
*/
|
||||
export function useA2AAgentCard(agentId: string) {
|
||||
return useQuery({
|
||||
queryKey: a2aAgentKeys.detail(agentId),
|
||||
queryFn: () => fetchA2AAgentCard(agentId),
|
||||
enabled: Boolean(agentId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create A2A agent params
|
||||
*/
|
||||
export interface CreateA2AAgentParams {
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
name?: string
|
||||
description?: string
|
||||
capabilities?: AgentCapabilities
|
||||
authentication?: AgentAuthentication
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new A2A agent
|
||||
*/
|
||||
async function createA2AAgent(params: CreateA2AAgentParams): Promise<A2AAgent> {
|
||||
const response = await fetch('/api/a2a/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to create A2A agent')
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create an A2A agent
|
||||
*/
|
||||
export function useCreateA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createA2AAgent,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(data.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update A2A agent params
|
||||
*/
|
||||
export interface UpdateA2AAgentParams {
|
||||
agentId: string
|
||||
name?: string
|
||||
description?: string
|
||||
version?: string
|
||||
capabilities?: AgentCapabilities
|
||||
skills?: AgentSkill[]
|
||||
authentication?: AgentAuthentication
|
||||
isPublished?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an A2A agent
|
||||
*/
|
||||
async function updateA2AAgent(params: UpdateA2AAgentParams): Promise<A2AAgent> {
|
||||
const { agentId, ...body } = params
|
||||
const response = await fetch(`/api/a2a/agents/${agentId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update A2A agent')
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update an A2A agent
|
||||
*/
|
||||
export function useUpdateA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateA2AAgent,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.detail(data.id),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(data.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an A2A agent
|
||||
*/
|
||||
async function deleteA2AAgent(params: { agentId: string; workspaceId: string }): Promise<void> {
|
||||
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete A2A agent')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete an A2A agent
|
||||
*/
|
||||
export function useDeleteA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteA2AAgent,
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish/unpublish agent params
|
||||
*/
|
||||
export interface PublishA2AAgentParams {
|
||||
agentId: string
|
||||
workspaceId: string
|
||||
action: 'publish' | 'unpublish' | 'refresh'
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish or unpublish an A2A agent
|
||||
*/
|
||||
async function publishA2AAgent(params: PublishA2AAgentParams): Promise<{
|
||||
isPublished?: boolean
|
||||
skills?: AgentSkill[]
|
||||
}> {
|
||||
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: params.action }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update A2A agent')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to publish/unpublish an A2A agent
|
||||
*/
|
||||
export function usePublishA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: publishA2AAgent,
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.detail(variables.agentId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -15,18 +15,26 @@ export interface BYOKKey {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BYOKKeysResponse {
|
||||
keys: BYOKKey[]
|
||||
byokEnabled: boolean
|
||||
}
|
||||
|
||||
export const byokKeysKeys = {
|
||||
all: ['byok-keys'] as const,
|
||||
workspace: (workspaceId: string) => [...byokKeysKeys.all, 'workspace', workspaceId] as const,
|
||||
}
|
||||
|
||||
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKey[]> {
|
||||
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKeysResponse> {
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load BYOK keys: ${response.statusText}`)
|
||||
}
|
||||
const { keys } = await response.json()
|
||||
return keys
|
||||
const data = await response.json()
|
||||
return {
|
||||
keys: data.keys ?? [],
|
||||
byokEnabled: data.byokEnabled ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
export function useBYOKKeys(workspaceId: string) {
|
||||
@@ -36,6 +44,7 @@ export function useBYOKKeys(workspaceId: string) {
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
select: (data) => data,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ export const logKeys = {
|
||||
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
|
||||
dashboard: (workspaceId: string | undefined, filters: Record<string, unknown>) =>
|
||||
[...logKeys.all, 'dashboard', workspaceId ?? '', filters] as const,
|
||||
executionSnapshots: () => [...logKeys.all, 'executionSnapshot'] as const,
|
||||
executionSnapshot: (executionId: string | undefined) =>
|
||||
[...logKeys.executionSnapshots(), executionId ?? ''] as const,
|
||||
}
|
||||
|
||||
interface LogFilters {
|
||||
@@ -196,3 +199,45 @@ export function useDashboardLogs(
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export interface ExecutionSnapshotData {
|
||||
executionId: string
|
||||
workflowId: string
|
||||
workflowState: Record<string, unknown>
|
||||
executionMetadata: {
|
||||
trigger: string
|
||||
startedAt: string
|
||||
endedAt?: string
|
||||
totalDurationMs?: number
|
||||
cost: {
|
||||
total: number | null
|
||||
input: number | null
|
||||
output: number | null
|
||||
}
|
||||
totalTokens: number | null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExecutionSnapshot(executionId: string): Promise<ExecutionSnapshotData> {
|
||||
const response = await fetch(`/api/logs/execution/${executionId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch execution snapshot: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!data) {
|
||||
throw new Error('No execution snapshot data returned')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function useExecutionSnapshot(executionId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.executionSnapshot(executionId),
|
||||
queryFn: () => fetchExecutionSnapshot(executionId as string),
|
||||
enabled: Boolean(executionId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getNextWorkflowColor,
|
||||
} from '@/stores/workflows/registry/utils'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowQueries')
|
||||
|
||||
@@ -20,6 +21,9 @@ export const workflowKeys = {
|
||||
all: ['workflows'] as const,
|
||||
lists: () => [...workflowKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined) => [...workflowKeys.lists(), workspaceId ?? ''] as const,
|
||||
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
||||
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
||||
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
||||
}
|
||||
|
||||
function mapWorkflow(workflow: any): WorkflowMetadata {
|
||||
@@ -339,3 +343,60 @@ export function useDuplicateWorkflowMutation() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DeploymentVersionStateResponse {
|
||||
deployedState: WorkflowState
|
||||
}
|
||||
|
||||
async function fetchDeploymentVersionState(
|
||||
workflowId: string,
|
||||
version: number
|
||||
): Promise<WorkflowState> {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch deployment version: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: DeploymentVersionStateResponse = await response.json()
|
||||
if (!data.deployedState) {
|
||||
throw new Error('No deployed state returned')
|
||||
}
|
||||
|
||||
return data.deployedState
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching the workflow state of a specific deployment version.
|
||||
* Used in the deploy modal to preview historical versions.
|
||||
*/
|
||||
export function useDeploymentVersionState(workflowId: string | null, version: number | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined),
|
||||
queryFn: () => fetchDeploymentVersionState(workflowId as string, version as number),
|
||||
enabled: Boolean(workflowId) && version !== null,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - deployment versions don't change
|
||||
})
|
||||
}
|
||||
|
||||
interface RevertToVersionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for reverting (loading) a deployment version into the current workflow.
|
||||
*/
|
||||
export function useRevertToVersion() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, version }: RevertToVersionVariables): Promise<void> => {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load deployment')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* A2A Agent Card Generation
|
||||
*
|
||||
* Generates Agent Cards from workflow metadata and configuration.
|
||||
*/
|
||||
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import {
|
||||
A2A_DEFAULT_CAPABILITIES,
|
||||
A2A_DEFAULT_INPUT_MODES,
|
||||
A2A_DEFAULT_OUTPUT_MODES,
|
||||
} from './constants'
|
||||
import type {
|
||||
AgentAuthentication,
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentSkill,
|
||||
JSONSchema,
|
||||
} from './types'
|
||||
import { buildA2AEndpointUrl, sanitizeAgentName } from './utils'
|
||||
|
||||
interface WorkflowData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface AgentData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
version: string
|
||||
capabilities?: AgentCapabilities
|
||||
skills?: AgentSkill[]
|
||||
authentication?: AgentAuthentication
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an Agent Card from agent and workflow data
|
||||
*/
|
||||
export function generateAgentCard(agent: AgentData, workflow: WorkflowData): AgentCard {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
return {
|
||||
name: agent.name,
|
||||
description: agent.description || workflow.description || undefined,
|
||||
url: buildA2AEndpointUrl(baseUrl, agent.id),
|
||||
version: agent.version || '1.0.0',
|
||||
documentationUrl: `${baseUrl}/docs/a2a`,
|
||||
provider: {
|
||||
organization: 'Sim Studio',
|
||||
url: baseUrl,
|
||||
},
|
||||
capabilities: {
|
||||
...A2A_DEFAULT_CAPABILITIES,
|
||||
...agent.capabilities,
|
||||
},
|
||||
skills: agent.skills || [
|
||||
{
|
||||
id: 'execute',
|
||||
name: `Execute ${workflow.name}`,
|
||||
description: workflow.description || `Execute the ${workflow.name} workflow`,
|
||||
},
|
||||
],
|
||||
authentication: agent.authentication || {
|
||||
schemes: ['bearer', 'apiKey'],
|
||||
},
|
||||
defaultInputModes: [...A2A_DEFAULT_INPUT_MODES],
|
||||
defaultOutputModes: [...A2A_DEFAULT_OUTPUT_MODES],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate skills from workflow input format
|
||||
*/
|
||||
export function generateSkillsFromWorkflow(
|
||||
workflowId: string,
|
||||
workflowName: string,
|
||||
workflowDescription: string | undefined | null,
|
||||
blocks: Record<string, unknown>
|
||||
): AgentSkill[] {
|
||||
const inputFormat = extractInputFormatFromBlocks(blocks)
|
||||
|
||||
const skill: AgentSkill = {
|
||||
id: 'execute',
|
||||
name: `Execute ${workflowName}`,
|
||||
description: workflowDescription || `Execute the ${workflowName} workflow`,
|
||||
tags: ['workflow', 'automation'],
|
||||
}
|
||||
|
||||
if (inputFormat && inputFormat.length > 0) {
|
||||
skill.inputSchema = convertInputFormatToJSONSchema(inputFormat)
|
||||
}
|
||||
|
||||
// Add default output schema
|
||||
skill.outputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The main text output from the workflow',
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Structured data output from the workflow',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return [skill]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert InputFormatField array to JSON Schema
|
||||
*/
|
||||
export function convertInputFormatToJSONSchema(inputFormat: InputFormatField[]): JSONSchema {
|
||||
const mcpSchema = generateToolInputSchema(inputFormat)
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties: mcpSchema.properties as Record<string, JSONSchema>,
|
||||
required: mcpSchema.required,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a default agent name from workflow name
|
||||
*/
|
||||
export function generateDefaultAgentName(workflowName: string): string {
|
||||
return sanitizeAgentName(workflowName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate agent card structure
|
||||
*/
|
||||
export function validateAgentCard(card: unknown): card is AgentCard {
|
||||
if (!card || typeof card !== 'object') return false
|
||||
|
||||
const c = card as Record<string, unknown>
|
||||
|
||||
// Required fields
|
||||
if (typeof c.name !== 'string' || !c.name) return false
|
||||
if (typeof c.url !== 'string' || !c.url) return false
|
||||
if (typeof c.version !== 'string' || !c.version) return false
|
||||
|
||||
// Capabilities must be an object
|
||||
if (c.capabilities && typeof c.capabilities !== 'object') return false
|
||||
|
||||
// Skills must be an array
|
||||
if (!Array.isArray(c.skills)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge agent card with updates (partial update support)
|
||||
*/
|
||||
export function mergeAgentCard(existing: AgentCard, updates: Partial<AgentCard>): AgentCard {
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
capabilities: {
|
||||
...existing.capabilities,
|
||||
...updates.capabilities,
|
||||
},
|
||||
skills: updates.skills || existing.skills,
|
||||
authentication: updates.authentication || existing.authentication,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent card URL paths
|
||||
*/
|
||||
export function getAgentCardPaths(agentId: string) {
|
||||
const baseUrl = getBaseUrl()
|
||||
return {
|
||||
card: `${baseUrl}/api/a2a/agents/${agentId}`,
|
||||
serve: `${baseUrl}/api/a2a/serve/${agentId}`,
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* A2A Protocol Constants (v0.2.6)
|
||||
*/
|
||||
|
||||
/** A2A Protocol version */
|
||||
export const A2A_PROTOCOL_VERSION = '0.2.6'
|
||||
|
||||
/** Default timeout for A2A requests (5 minutes) */
|
||||
export const A2A_DEFAULT_TIMEOUT = 300000
|
||||
|
||||
/** Maximum message history length */
|
||||
export const A2A_MAX_HISTORY_LENGTH = 100
|
||||
|
||||
/** Supported authentication schemes */
|
||||
export const A2A_AUTH_SCHEMES = ['bearer', 'apiKey', 'oauth2', 'none'] as const
|
||||
|
||||
/** Task state values (v0.2.6) */
|
||||
export const A2A_TASK_STATE = {
|
||||
SUBMITTED: 'submitted',
|
||||
WORKING: 'working',
|
||||
INPUT_REQUIRED: 'input-required',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
CANCELED: 'canceled',
|
||||
REJECTED: 'rejected',
|
||||
AUTH_REQUIRED: 'auth-required',
|
||||
UNKNOWN: 'unknown',
|
||||
} as const
|
||||
|
||||
/** Valid task state transitions */
|
||||
export const A2A_VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
submitted: ['working', 'failed', 'canceled', 'rejected'],
|
||||
working: ['completed', 'failed', 'canceled', 'input-required'],
|
||||
'input-required': ['working', 'failed', 'canceled'],
|
||||
'auth-required': ['working', 'failed', 'canceled'],
|
||||
completed: [],
|
||||
failed: [],
|
||||
canceled: [],
|
||||
rejected: [],
|
||||
unknown: [],
|
||||
}
|
||||
|
||||
/** JSON-RPC methods supported by A2A */
|
||||
export const A2A_METHODS = {
|
||||
TASKS_SEND: 'tasks/send',
|
||||
TASKS_GET: 'tasks/get',
|
||||
TASKS_CANCEL: 'tasks/cancel',
|
||||
TASKS_SEND_SUBSCRIBE: 'tasks/sendSubscribe',
|
||||
} as const
|
||||
|
||||
/** Well-known path for agent card discovery */
|
||||
export const A2A_WELL_KNOWN_PATH = '/.well-known/agent.json'
|
||||
|
||||
/** Default capabilities for new agents */
|
||||
export const A2A_DEFAULT_CAPABILITIES = {
|
||||
streaming: true,
|
||||
pushNotifications: false,
|
||||
stateTransitionHistory: true,
|
||||
} as const
|
||||
|
||||
/** Default input/output modes */
|
||||
export const A2A_DEFAULT_INPUT_MODES = ['text', 'data'] as const
|
||||
export const A2A_DEFAULT_OUTPUT_MODES = ['text', 'data'] as const
|
||||
|
||||
/** Cache settings */
|
||||
export const A2A_CACHE = {
|
||||
AGENT_CARD_TTL: 3600, // 1 hour
|
||||
TASK_TTL: 86400, // 24 hours
|
||||
} as const
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* A2A (Agent-to-Agent) Protocol Implementation
|
||||
*
|
||||
* This module provides A2A protocol support for Sim Studio,
|
||||
* enabling workflows to be exposed as A2A agents and allowing
|
||||
* workflows to call external A2A agents.
|
||||
*/
|
||||
|
||||
export * from './agent-card'
|
||||
export * from './constants'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -1,368 +0,0 @@
|
||||
/**
|
||||
* A2A (Agent-to-Agent) Protocol Types
|
||||
*
|
||||
* Implements the A2A protocol specification for agent interoperability.
|
||||
* @see https://a2a-protocol.org/specification
|
||||
*/
|
||||
|
||||
/**
|
||||
* JSON Schema type for input/output definitions
|
||||
*/
|
||||
export interface JSONSchema {
|
||||
type: string
|
||||
properties?: Record<string, JSONSchema | JSONSchemaProperty>
|
||||
required?: string[]
|
||||
items?: JSONSchema
|
||||
description?: string
|
||||
enum?: string[]
|
||||
default?: unknown
|
||||
additionalProperties?: boolean | JSONSchema
|
||||
}
|
||||
|
||||
export interface JSONSchemaProperty {
|
||||
type: string
|
||||
description?: string
|
||||
enum?: string[]
|
||||
default?: unknown
|
||||
items?: JSONSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Card - Discovery document for A2A agents
|
||||
* Describes an agent's capabilities, skills, and how to interact with it
|
||||
*/
|
||||
export interface AgentCard {
|
||||
/** Human-readable name of the agent */
|
||||
name: string
|
||||
/** Description of what the agent does */
|
||||
description?: string
|
||||
/** Base URL for the agent's A2A endpoint */
|
||||
url: string
|
||||
/** Version of the agent implementation */
|
||||
version: string
|
||||
/** URL to agent documentation */
|
||||
documentationUrl?: string
|
||||
/** Provider information */
|
||||
provider?: AgentProvider
|
||||
/** Agent capabilities */
|
||||
capabilities: AgentCapabilities
|
||||
/** Skills the agent can perform */
|
||||
skills: AgentSkill[]
|
||||
/** Authentication configuration */
|
||||
authentication?: AgentAuthentication
|
||||
/** Default input modes accepted */
|
||||
defaultInputModes?: InputMode[]
|
||||
/** Default output modes produced */
|
||||
defaultOutputModes?: OutputMode[]
|
||||
}
|
||||
|
||||
export interface AgentProvider {
|
||||
organization: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface AgentCapabilities {
|
||||
/** Whether the agent supports streaming responses */
|
||||
streaming?: boolean
|
||||
/** Whether the agent supports push notifications */
|
||||
pushNotifications?: boolean
|
||||
/** Whether the agent tracks state transition history */
|
||||
stateTransitionHistory?: boolean
|
||||
}
|
||||
|
||||
export interface AgentSkill {
|
||||
/** Unique identifier for the skill */
|
||||
id: string
|
||||
/** Human-readable name */
|
||||
name: string
|
||||
/** Description of what the skill does */
|
||||
description?: string
|
||||
/** Tags for categorization */
|
||||
tags?: string[]
|
||||
/** JSON Schema for input parameters */
|
||||
inputSchema?: JSONSchema
|
||||
/** JSON Schema for output */
|
||||
outputSchema?: JSONSchema
|
||||
/** Example interactions */
|
||||
examples?: SkillExample[]
|
||||
}
|
||||
|
||||
export interface SkillExample {
|
||||
input: TaskMessage
|
||||
output: TaskMessage[]
|
||||
}
|
||||
|
||||
export interface AgentAuthentication {
|
||||
/** Supported authentication schemes */
|
||||
schemes: AuthScheme[]
|
||||
/** Credentials hint or reference */
|
||||
credentials?: string
|
||||
}
|
||||
|
||||
export type AuthScheme = 'bearer' | 'apiKey' | 'oauth2' | 'none'
|
||||
export type InputMode = 'text' | 'file' | 'data'
|
||||
export type OutputMode = 'text' | 'file' | 'data'
|
||||
|
||||
/**
|
||||
* Task - Core unit of work in A2A protocol (v0.2.6)
|
||||
*/
|
||||
export interface Task {
|
||||
/** Unique task identifier */
|
||||
id: string
|
||||
/** Server-generated context ID for contextual alignment across interactions */
|
||||
contextId?: string
|
||||
/** Current task status */
|
||||
status: TaskStatusObject
|
||||
/** Message history */
|
||||
history?: TaskMessage[]
|
||||
/** Structured output artifacts */
|
||||
artifacts?: Artifact[]
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
/** Event kind - always "task" */
|
||||
kind?: 'task'
|
||||
}
|
||||
|
||||
/**
|
||||
* Task state lifecycle (v0.2.6)
|
||||
*/
|
||||
export type TaskState =
|
||||
| 'submitted'
|
||||
| 'working'
|
||||
| 'input-required'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled'
|
||||
| 'rejected'
|
||||
| 'auth-required'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Task status object (v0.2.6)
|
||||
* Represents the current state and associated context of a Task
|
||||
*/
|
||||
export interface TaskStatusObject {
|
||||
/** The current lifecycle state of the task */
|
||||
state: TaskState
|
||||
/** Additional status updates for the client */
|
||||
message?: TaskMessage
|
||||
/** ISO 8601 datetime string indicating when the status was recorded */
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy TaskStatus type for backward compatibility
|
||||
* @deprecated Use TaskState instead
|
||||
*/
|
||||
export type TaskStatus = TaskState
|
||||
|
||||
/**
|
||||
* Task message - A single message in a task conversation
|
||||
*/
|
||||
export interface TaskMessage {
|
||||
/** Message role */
|
||||
role: 'user' | 'agent'
|
||||
/** Message content parts */
|
||||
parts: MessagePart[]
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Message part types
|
||||
*/
|
||||
export type MessagePart = TextPart | FilePart | DataPart
|
||||
|
||||
export interface TextPart {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface FilePart {
|
||||
type: 'file'
|
||||
file: FileContent
|
||||
}
|
||||
|
||||
export interface FileContent {
|
||||
name?: string
|
||||
mimeType?: string
|
||||
/** Base64 encoded content */
|
||||
bytes?: string
|
||||
/** URI reference to file */
|
||||
uri?: string
|
||||
}
|
||||
|
||||
export interface DataPart {
|
||||
type: 'data'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact - Structured output from an agent
|
||||
*/
|
||||
export interface Artifact {
|
||||
/** Artifact name */
|
||||
name?: string
|
||||
/** Description of the artifact */
|
||||
description?: string
|
||||
/** Content parts */
|
||||
parts: MessagePart[]
|
||||
/** Index for ordering */
|
||||
index: number
|
||||
/** Whether to append to existing artifact */
|
||||
append?: boolean
|
||||
/** Whether this is the last chunk (for streaming) */
|
||||
lastChunk?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC Request Parameters (v0.2.6)
|
||||
*/
|
||||
export interface TaskSendParams {
|
||||
/** Task ID (optional for new tasks) */
|
||||
id?: string
|
||||
/** Context ID for contextual alignment across interactions */
|
||||
contextId?: string
|
||||
/** Message to send */
|
||||
message: TaskMessage
|
||||
/** Accepted output modes */
|
||||
acceptedOutputModes?: OutputMode[]
|
||||
/** Push notification configuration */
|
||||
pushNotificationConfig?: PushNotificationConfig
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TaskQueryParams {
|
||||
/** Task ID to query */
|
||||
id: string
|
||||
/** Number of history messages to include */
|
||||
historyLength?: number
|
||||
}
|
||||
|
||||
export interface TaskCancelParams {
|
||||
/** Task ID to cancel */
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface PushNotificationConfig {
|
||||
/** Webhook URL for notifications */
|
||||
url: string
|
||||
/** Authentication token */
|
||||
token?: string
|
||||
/** Authentication configuration */
|
||||
authentication?: {
|
||||
schemes: string[]
|
||||
credentials?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task status update event (for streaming)
|
||||
*/
|
||||
export interface TaskStatusUpdate {
|
||||
/** Task ID */
|
||||
id: string
|
||||
/** Updated status */
|
||||
status: TaskStatusObject
|
||||
/** Final result (if completed) */
|
||||
final?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Task artifact update event (for streaming)
|
||||
*/
|
||||
export interface TaskArtifactUpdate {
|
||||
/** Task ID */
|
||||
id: string
|
||||
/** Artifact being updated */
|
||||
artifact: Artifact
|
||||
}
|
||||
|
||||
/**
|
||||
* A2A Error codes (aligned with JSON-RPC)
|
||||
*/
|
||||
export const A2AErrorCode = {
|
||||
// Standard JSON-RPC errors
|
||||
PARSE_ERROR: -32700,
|
||||
INVALID_REQUEST: -32600,
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
INVALID_PARAMS: -32602,
|
||||
INTERNAL_ERROR: -32603,
|
||||
|
||||
// A2A-specific errors
|
||||
TASK_NOT_FOUND: -32001,
|
||||
TASK_ALREADY_COMPLETE: -32002,
|
||||
AGENT_UNAVAILABLE: -32003,
|
||||
SKILL_NOT_FOUND: -32004,
|
||||
AUTHENTICATION_REQUIRED: -32005,
|
||||
RATE_LIMITED: -32006,
|
||||
} as const
|
||||
|
||||
export type A2AErrorCodeType = (typeof A2AErrorCode)[keyof typeof A2AErrorCode]
|
||||
|
||||
/**
|
||||
* A2A Error class
|
||||
*/
|
||||
export class A2AError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: A2AErrorCodeType = A2AErrorCode.INTERNAL_ERROR,
|
||||
public data?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'A2AError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A2A API Response wrapper
|
||||
*/
|
||||
export interface A2AApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Database model types
|
||||
*/
|
||||
export interface A2AAgentConfig {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
name: string
|
||||
description?: string
|
||||
version: string
|
||||
capabilities: AgentCapabilities
|
||||
skills: AgentSkill[]
|
||||
authentication: AgentAuthentication
|
||||
isPublished: boolean
|
||||
publishedAt?: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface A2ATaskRecord {
|
||||
id: string
|
||||
agentId: string
|
||||
contextId?: string
|
||||
status: TaskState
|
||||
history: TaskMessage[]
|
||||
artifacts?: Artifact[]
|
||||
executionId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE Event types for streaming
|
||||
*/
|
||||
export type A2AStreamEvent =
|
||||
| { type: 'task:status'; data: TaskStatusUpdate }
|
||||
| { type: 'task:artifact'; data: TaskArtifactUpdate }
|
||||
| { type: 'task:message'; data: { id: string; message: TaskMessage } }
|
||||
| { type: 'task:done'; data: { id: string } }
|
||||
| { type: 'error'; data: { code: number; message: string } }
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* A2A Protocol Utilities
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { A2A_VALID_TRANSITIONS } from './constants'
|
||||
import type { MessagePart, Task, TaskMessage, TaskState, TaskStatusObject, TextPart } from './types'
|
||||
|
||||
/**
|
||||
* Generate a unique task ID
|
||||
*/
|
||||
export function generateTaskId(): string {
|
||||
return uuidv4()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique context ID
|
||||
*/
|
||||
export function generateContextId(): string {
|
||||
return `ctx_${uuidv4()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task status transition is valid
|
||||
*/
|
||||
export function isValidStatusTransition(from: TaskState, to: TaskState): boolean {
|
||||
const validTransitions = A2A_VALID_TRANSITIONS[from]
|
||||
return validTransitions?.includes(to) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is in a terminal state
|
||||
*/
|
||||
export function isTerminalState(state: TaskState): boolean {
|
||||
return state === 'completed' || state === 'failed' || state === 'canceled' || state === 'rejected'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TaskStatusObject from a state
|
||||
*/
|
||||
export function createTaskStatus(state: TaskState, message?: TaskMessage): TaskStatusObject {
|
||||
return {
|
||||
state,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a text message part
|
||||
*/
|
||||
export function createTextPart(text: string): TextPart {
|
||||
return { type: 'text', text }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user message
|
||||
*/
|
||||
export function createUserMessage(content: string | MessagePart[]): TaskMessage {
|
||||
const parts = typeof content === 'string' ? [createTextPart(content)] : content
|
||||
return { role: 'user', parts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent message
|
||||
*/
|
||||
export function createAgentMessage(content: string | MessagePart[]): TaskMessage {
|
||||
const parts = typeof content === 'string' ? [createTextPart(content)] : content
|
||||
return { role: 'agent', parts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from a message
|
||||
*/
|
||||
export function extractTextContent(message: TaskMessage): string {
|
||||
return message.parts
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from multiple messages
|
||||
*/
|
||||
export function extractConversationText(messages: TaskMessage[]): string {
|
||||
return messages.map((m) => `${m.role}: ${extractTextContent(m)}`).join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an A2A tool ID from agent ID and skill ID
|
||||
*/
|
||||
export function createA2AToolId(agentId: string, skillId: string): string {
|
||||
return `a2a:${agentId}:${skillId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an A2A tool ID into components
|
||||
*/
|
||||
export function parseA2AToolId(toolId: string): { agentId: string; skillId: string } | null {
|
||||
const parts = toolId.split(':')
|
||||
if (parts.length !== 3 || parts[0] !== 'a2a') {
|
||||
return null
|
||||
}
|
||||
return { agentId: parts[1], skillId: parts[2] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize agent name for use as identifier
|
||||
*/
|
||||
export function sanitizeAgentName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task structure
|
||||
*/
|
||||
export function validateTask(task: unknown): task is Task {
|
||||
if (!task || typeof task !== 'object') return false
|
||||
const t = task as Record<string, unknown>
|
||||
|
||||
if (typeof t.id !== 'string') return false
|
||||
if (!t.status || typeof t.status !== 'object') return false
|
||||
const status = t.status as Record<string, unknown>
|
||||
if (typeof status.state !== 'string') return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal task object
|
||||
*/
|
||||
export function createTask(params: {
|
||||
id?: string
|
||||
contextId?: string
|
||||
state?: TaskState
|
||||
history?: TaskMessage[]
|
||||
metadata?: Record<string, unknown>
|
||||
}): Task {
|
||||
return {
|
||||
id: params.id || generateTaskId(),
|
||||
contextId: params.contextId,
|
||||
status: createTaskStatus(params.state || 'submitted'),
|
||||
history: params.history || [],
|
||||
artifacts: [],
|
||||
metadata: params.metadata,
|
||||
kind: 'task',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format task for API response (remove internal fields)
|
||||
*/
|
||||
export function formatTaskResponse(task: Task, historyLength?: number): Task {
|
||||
let history = task.history || []
|
||||
if (historyLength !== undefined && historyLength >= 0) {
|
||||
history = history.slice(-historyLength)
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
contextId: task.contextId,
|
||||
status: task.status,
|
||||
history,
|
||||
artifacts: task.artifacts,
|
||||
metadata: task.metadata,
|
||||
kind: 'task',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build A2A endpoint URL
|
||||
*/
|
||||
export function buildA2AEndpointUrl(baseUrl: string, agentId: string): string {
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
return `${base}/api/a2a/serve/${agentId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Agent Card URL
|
||||
*/
|
||||
export function buildAgentCardUrl(baseUrl: string, agentId: string): string {
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
return `${base}/api/a2a/agents/${agentId}`
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { db } from '@sim/db'
|
||||
import { workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getHostedModels } from '@/providers/models'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
const logger = createLogger('BYOKKeys')
|
||||
|
||||
@@ -51,9 +56,6 @@ export async function getApiKeyWithBYOK(
|
||||
workspaceId: string | undefined | null,
|
||||
userProvidedKey?: string
|
||||
): Promise<{ apiKey: string; isBYOK: boolean }> {
|
||||
const { isHosted } = await import('@/lib/core/config/feature-flags')
|
||||
const { useProvidersStore } = await import('@/stores/providers/store')
|
||||
|
||||
const isOllamaModel =
|
||||
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
|
||||
if (isOllamaModel) {
|
||||
@@ -83,23 +85,27 @@ export async function getApiKeyWithBYOK(
|
||||
workspaceId &&
|
||||
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)
|
||||
) {
|
||||
const { getHostedModels } = await import('@/providers/models')
|
||||
const hostedModels = getHostedModels()
|
||||
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
|
||||
|
||||
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
|
||||
|
||||
if (isModelHosted || isMistralModel) {
|
||||
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key', { provider, model, workspaceId })
|
||||
return byokResult
|
||||
const hasEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
|
||||
if (hasEnterprise) {
|
||||
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key', { provider, model, workspaceId })
|
||||
return byokResult
|
||||
}
|
||||
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
|
||||
} else {
|
||||
logger.debug('Workspace not on enterprise plan, skipping BYOK', { workspaceId })
|
||||
}
|
||||
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
|
||||
|
||||
if (isModelHosted) {
|
||||
try {
|
||||
const { getRotatingApiKey } = await import('@/lib/core/config/api-keys')
|
||||
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
|
||||
return { apiKey: serverKey, isBYOK: false }
|
||||
} catch (_error) {
|
||||
|
||||
@@ -113,9 +113,8 @@ export async function checkHybridAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try API key auth (check both X-API-Key and Authorization: Bearer as fallback)
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const apiKeyHeader = request.headers.get('x-api-key') || bearerToken
|
||||
// 3. Try API key auth
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (result.success) {
|
||||
|
||||
53
apps/sim/lib/billing/core/plan.ts
Normal file
53
apps/sim/lib/billing/core/plan.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
const logger = createLogger('PlanLookup')
|
||||
|
||||
/**
|
||||
* Get the highest priority active subscription for a user
|
||||
* Priority: Enterprise > Team > Pro > Free
|
||||
*/
|
||||
export async function getHighestPrioritySubscription(userId: string) {
|
||||
try {
|
||||
const personalSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
|
||||
|
||||
let orgSubs: typeof personalSubs = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
if (allSubs.length === 0) return null
|
||||
|
||||
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
|
||||
if (enterpriseSub) return enterpriseSub
|
||||
|
||||
const teamSub = allSubs.find((s) => checkTeamPlan(s))
|
||||
if (teamSub) return teamSub
|
||||
|
||||
const proSub = allSubs.find((s) => checkProPlan(s))
|
||||
if (proSub) return proSub
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error getting highest priority subscription', { error, userId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { member, subscription, user, userStats, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { getUserUsageLimit } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
checkEnterprisePlan,
|
||||
checkProPlan,
|
||||
@@ -10,65 +12,17 @@ import {
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import {
|
||||
isCredentialSetsEnabled,
|
||||
isHosted,
|
||||
isProd,
|
||||
isSsoEnabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('SubscriptionCore')
|
||||
|
||||
/**
|
||||
* Core subscription management - single source of truth
|
||||
* Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the highest priority active subscription for a user
|
||||
* Priority: Enterprise > Team > Pro > Free
|
||||
*/
|
||||
export async function getHighestPrioritySubscription(userId: string) {
|
||||
try {
|
||||
// Get direct subscriptions
|
||||
const personalSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
|
||||
// Get organization memberships
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
|
||||
|
||||
// Get organization subscriptions
|
||||
let orgSubs: any[] = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
if (allSubs.length === 0) return null
|
||||
|
||||
// Return highest priority subscription
|
||||
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
|
||||
if (enterpriseSub) return enterpriseSub
|
||||
|
||||
const teamSub = allSubs.find((s) => checkTeamPlan(s))
|
||||
if (teamSub) return teamSub
|
||||
|
||||
const proSub = allSubs.find((s) => checkProPlan(s))
|
||||
if (proSub) return proSub
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error getting highest priority subscription', { error, userId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
export { getHighestPrioritySubscription }
|
||||
|
||||
/**
|
||||
* Check if user is on Pro plan (direct or via organization)
|
||||
@@ -144,6 +98,224 @@ export async function isEnterprisePlan(userId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is an admin or owner of an enterprise organization
|
||||
* Returns true if:
|
||||
* - User is a member of an enterprise organization AND
|
||||
* - User's role in that organization is 'owner' or 'admin'
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [memberRecord] = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!memberRecord) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
|
||||
return false
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, memberRecord.organizationId),
|
||||
eq(subscription.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
|
||||
|
||||
if (isEnterprise) {
|
||||
logger.info('User is enterprise org admin/owner', {
|
||||
userId,
|
||||
organizationId: memberRecord.organizationId,
|
||||
role: memberRecord.role,
|
||||
})
|
||||
}
|
||||
|
||||
return !!isEnterprise
|
||||
} catch (error) {
|
||||
logger.error('Error checking enterprise org admin/owner status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is an admin or owner of a team or enterprise organization
|
||||
* Returns true if:
|
||||
* - User is a member of a team/enterprise organization AND
|
||||
* - User's role in that organization is 'owner' or 'admin'
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function isTeamOrgAdminOrOwner(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [memberRecord] = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!memberRecord) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
|
||||
return false
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, memberRecord.organizationId),
|
||||
eq(subscription.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
|
||||
|
||||
if (hasTeamPlan) {
|
||||
logger.info('User is team org admin/owner', {
|
||||
userId,
|
||||
organizationId: memberRecord.organizationId,
|
||||
role: memberRecord.role,
|
||||
plan: orgSub.plan,
|
||||
})
|
||||
}
|
||||
|
||||
return !!hasTeamPlan
|
||||
} catch (error) {
|
||||
logger.error('Error checking team org admin/owner status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workspace has access to enterprise features (BYOK)
|
||||
* Used at execution time to determine if BYOK keys should be used
|
||||
* Returns true if workspace's billed account is on enterprise plan
|
||||
*/
|
||||
export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [ws] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!ws) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isEnterprisePlan(ws.billedAccountUserId)
|
||||
} catch (error) {
|
||||
logger.error('Error checking workspace enterprise status', { error, workspaceId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an organization has team or enterprise plan
|
||||
* Used at execution time (e.g., polling services) to check org billing directly
|
||||
*/
|
||||
export async function isOrganizationOnTeamOrEnterprisePlan(
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isCredentialSetsEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
|
||||
} catch (error) {
|
||||
logger.error('Error checking organization plan status', { error, organizationId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to credential sets (email polling) feature
|
||||
* Returns true if:
|
||||
* - CREDENTIAL_SETS_ENABLED env var is set (self-hosted override), OR
|
||||
* - User is admin/owner of a team/enterprise organization
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function hasCredentialSetsAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (isCredentialSetsEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isTeamOrgAdminOrOwner(userId)
|
||||
} catch (error) {
|
||||
logger.error('Error checking credential sets access', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to SSO feature
|
||||
* Returns true if:
|
||||
* - SSO_ENABLED env var is set (self-hosted override), OR
|
||||
* - User is admin/owner of an enterprise organization
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function hasSSOAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (isSsoEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isEnterpriseOrgAdminOrOwner(userId)
|
||||
} catch (error) {
|
||||
logger.error('Error checking SSO access', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded their cost limit based on current period usage
|
||||
*/
|
||||
@@ -160,7 +332,6 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
if (subscription) {
|
||||
// Team/Enterprise: Use organization limit
|
||||
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
|
||||
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
|
||||
limit = await getUserUsageLimit(userId)
|
||||
logger.info('Using organization limit', {
|
||||
userId,
|
||||
@@ -221,14 +392,16 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
// Determine plan types based on subscription (avoid redundant DB calls)
|
||||
const isPro =
|
||||
!isProd ||
|
||||
(subscription &&
|
||||
!!(
|
||||
subscription &&
|
||||
(checkProPlan(subscription) ||
|
||||
checkTeamPlan(subscription) ||
|
||||
checkEnterprisePlan(subscription)))
|
||||
checkEnterprisePlan(subscription))
|
||||
)
|
||||
const isTeam =
|
||||
!isProd ||
|
||||
(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
|
||||
const isEnterprise = !isProd || (subscription && checkEnterprisePlan(subscription))
|
||||
!!(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
|
||||
const isEnterprise = !isProd || !!(subscription && checkEnterprisePlan(subscription))
|
||||
const isFree = !isPro && !isTeam && !isEnterprise
|
||||
|
||||
// Determine plan name
|
||||
@@ -244,7 +417,6 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
if (subscription) {
|
||||
// Team/Enterprise: Use organization limit
|
||||
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
|
||||
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
|
||||
limit = await getUserUsageLimit(userId)
|
||||
} else {
|
||||
// Pro/Free: Use individual limit
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
renderFreeTierUpgradeEmail,
|
||||
renderUsageThresholdEmail,
|
||||
} from '@/components/emails'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import {
|
||||
canEditUsageLimit,
|
||||
getFreeTierLimit,
|
||||
|
||||
@@ -10,9 +10,15 @@ export * from '@/lib/billing/core/subscription'
|
||||
export {
|
||||
getHighestPrioritySubscription as getActiveSubscription,
|
||||
getUserSubscriptionState as getSubscriptionState,
|
||||
hasCredentialSetsAccess,
|
||||
hasSSOAccess,
|
||||
isEnterpriseOrgAdminOrOwner,
|
||||
isEnterprisePlan as hasEnterprisePlan,
|
||||
isOrganizationOnTeamOrEnterprisePlan,
|
||||
isProPlan as hasProPlan,
|
||||
isTeamOrgAdminOrOwner,
|
||||
isTeamPlan as hasTeamPlan,
|
||||
isWorkspaceOnEnterprisePlan,
|
||||
sendPlanWelcomeEmail,
|
||||
} from '@/lib/billing/core/subscription'
|
||||
export * from '@/lib/billing/core/usage'
|
||||
|
||||
@@ -248,6 +248,9 @@ export const env = createEnv({
|
||||
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
|
||||
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
|
||||
|
||||
// Credential Sets (Email Polling) - for self-hosted deployments
|
||||
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
|
||||
|
||||
// SSO Configuration (for script-based registration)
|
||||
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
|
||||
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type
|
||||
@@ -325,6 +328,7 @@ export const env = createEnv({
|
||||
// Feature Flags
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
|
||||
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
|
||||
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
|
||||
},
|
||||
|
||||
@@ -353,6 +357,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR,
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: process.env.NEXT_PUBLIC_TRIGGER_DEV_ENABLED,
|
||||
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
|
||||
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED,
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
|
||||
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
|
||||
|
||||
@@ -80,6 +80,12 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
*/
|
||||
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
|
||||
|
||||
/**
|
||||
* Is credential sets (email polling) enabled via env var override
|
||||
* This bypasses plan requirements for self-hosted deployments
|
||||
*/
|
||||
export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED)
|
||||
|
||||
/**
|
||||
* Is E2B enabled for remote code execution
|
||||
*/
|
||||
|
||||
@@ -595,26 +595,6 @@ describe('validateUrlWithDNS', () => {
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DNS resolution', () => {
|
||||
it('should accept valid public URLs and return resolved IP', async () => {
|
||||
const result = await validateUrlWithDNS('https://example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.resolvedIP).toBeDefined()
|
||||
expect(result.originalHostname).toBe('example.com')
|
||||
})
|
||||
|
||||
it('should reject URLs that resolve to private IPs', async () => {
|
||||
const result = await validateUrlWithDNS('https://localhost.localdomain')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject unresolvable hostnames', async () => {
|
||||
const result = await validateUrlWithDNS('https://this-domain-does-not-exist-xyz123.invalid')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('could not be resolved')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPinnedUrl', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, webhook, workflow } from '@sim/db/schema'
|
||||
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
||||
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -141,9 +142,9 @@ export async function pollGmailWebhooks() {
|
||||
|
||||
try {
|
||||
const metadata = webhookData.providerConfig as any
|
||||
// Each webhook now has its own credentialId (credential sets are fanned out at save time)
|
||||
const credentialId: string | undefined = metadata?.credentialId
|
||||
const userId: string | undefined = metadata?.userId
|
||||
const credentialSetId: string | undefined = metadata?.credentialSetId
|
||||
|
||||
if (!credentialId && !userId) {
|
||||
logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`)
|
||||
@@ -152,6 +153,31 @@ export async function pollGmailWebhooks() {
|
||||
return
|
||||
}
|
||||
|
||||
if (credentialSetId) {
|
||||
const [cs] = await db
|
||||
.select({ organizationId: credentialSet.organizationId })
|
||||
.from(credentialSet)
|
||||
.where(eq(credentialSet.id, credentialSetId))
|
||||
.limit(1)
|
||||
|
||||
if (cs?.organizationId) {
|
||||
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
|
||||
if (!hasAccess) {
|
||||
logger.error(
|
||||
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
|
||||
{
|
||||
webhookId,
|
||||
credentialSetId,
|
||||
organizationId: cs.organizationId,
|
||||
}
|
||||
)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (credentialId) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, webhook, workflow } from '@sim/db/schema'
|
||||
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { htmlToText } from 'html-to-text'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
||||
import { pollingIdempotency } from '@/lib/core/idempotency'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -192,6 +193,7 @@ export async function pollOutlookWebhooks() {
|
||||
const metadata = webhookData.providerConfig as any
|
||||
const credentialId: string | undefined = metadata?.credentialId
|
||||
const userId: string | undefined = metadata?.userId
|
||||
const credentialSetId: string | undefined = metadata?.credentialSetId
|
||||
|
||||
if (!credentialId && !userId) {
|
||||
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
|
||||
@@ -200,6 +202,31 @@ export async function pollOutlookWebhooks() {
|
||||
return
|
||||
}
|
||||
|
||||
if (credentialSetId) {
|
||||
const [cs] = await db
|
||||
.select({ organizationId: credentialSet.organizationId })
|
||||
.from(credentialSet)
|
||||
.where(eq(credentialSet.id, credentialSetId))
|
||||
.limit(1)
|
||||
|
||||
if (cs?.organizationId) {
|
||||
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
|
||||
if (!hasAccess) {
|
||||
logger.error(
|
||||
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
|
||||
{
|
||||
webhookId,
|
||||
credentialSetId,
|
||||
organizationId: cs.organizationId,
|
||||
}
|
||||
)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null
|
||||
if (credentialId) {
|
||||
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
@@ -239,6 +239,19 @@ export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: strin
|
||||
}
|
||||
}
|
||||
|
||||
if (webhook.provider === 'grain') {
|
||||
const eventTypes = providerConfig.eventTypes
|
||||
if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) {
|
||||
const eventType = body?.type
|
||||
if (eventType && !eventTypes.includes(eventType)) {
|
||||
logger.info(
|
||||
`[${requestId}] Grain event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping`
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -2546,7 +2546,6 @@ export async function syncWebhooksForCredentialSet(params: {
|
||||
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
|
||||
const useUniquePaths = pollingProviders.includes(provider)
|
||||
|
||||
// Get all credentials in the set
|
||||
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
|
||||
|
||||
if (credentials.length === 0) {
|
||||
|
||||
@@ -7,6 +7,19 @@ const APP_COLORS = [
|
||||
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
|
||||
]
|
||||
|
||||
/**
|
||||
* User color palette matching terminal.tsx RUN_ID_COLORS
|
||||
* These colors are used consistently across cursors, avatars, and terminal run IDs
|
||||
*/
|
||||
export const USER_COLORS = [
|
||||
'#4ADE80', // Green
|
||||
'#F472B6', // Pink
|
||||
'#60C5FF', // Blue
|
||||
'#FF8533', // Orange
|
||||
'#C084FC', // Purple
|
||||
'#FCD34D', // Yellow
|
||||
] as const
|
||||
|
||||
interface PresenceColorPalette {
|
||||
gradient: string
|
||||
accentColor: string
|
||||
@@ -80,3 +93,35 @@ export function getPresenceColors(
|
||||
baseColor: colorPair.from,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a consistent color for a user based on their ID.
|
||||
* The same user will always get the same color across cursors, avatars, and terminal.
|
||||
*
|
||||
* @param userId - The unique user identifier
|
||||
* @returns A hex color string
|
||||
*/
|
||||
export function getUserColor(userId: string): string {
|
||||
const hash = hashIdentifier(userId)
|
||||
return USER_COLORS[hash % USER_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stable mapping of user IDs to color indices for a list of users.
|
||||
* Useful when you need to maintain consistent color assignments across renders.
|
||||
*
|
||||
* @param userIds - Array of user IDs to map
|
||||
* @returns Map of user ID to color index
|
||||
*/
|
||||
export function createUserColorMap(userIds: string[]): Map<string, number> {
|
||||
const colorMap = new Map<string, number>()
|
||||
let colorIndex = 0
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (!colorMap.has(userId)) {
|
||||
colorMap.set(userId, colorIndex++)
|
||||
}
|
||||
}
|
||||
|
||||
return colorMap
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* A2A Cancel Task Tool
|
||||
*
|
||||
* Cancel a running A2A task.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { A2A_METHODS } from '@/lib/a2a/constants'
|
||||
import type { Task } from '@/lib/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
|
||||
|
||||
const logger = createLogger('A2ACancelTaskTool')
|
||||
|
||||
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
|
||||
id: 'a2a_cancel_task',
|
||||
name: 'A2A Cancel Task',
|
||||
description: 'Cancel a running A2A task.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to cancel',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: A2ACancelTaskParams) => params.agentUrl,
|
||||
method: 'POST',
|
||||
headers: (params: A2ACancelTaskParams) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (params.apiKey) {
|
||||
headers.Authorization = `Bearer ${params.apiKey}`
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params: A2ACancelTaskParams) => ({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now().toString(),
|
||||
method: A2A_METHODS.TASKS_CANCEL,
|
||||
params: {
|
||||
id: params.taskId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
try {
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
cancelled: false,
|
||||
state: 'failed',
|
||||
},
|
||||
error: result.error.message || 'A2A request failed',
|
||||
}
|
||||
}
|
||||
|
||||
const task = result.result as Task
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
cancelled: true,
|
||||
state: task.status.state,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing A2A response:', error)
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
cancelled: false,
|
||||
state: 'failed',
|
||||
},
|
||||
error: error instanceof Error ? error.message : 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
cancelled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether cancellation was successful',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state after cancellation',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* A2A Get Agent Card Tool
|
||||
*
|
||||
* Fetch the Agent Card (discovery document) for an A2A agent.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { AgentCard } from '@/lib/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
|
||||
|
||||
const logger = createLogger('A2AGetAgentCardTool')
|
||||
|
||||
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
|
||||
id: 'a2a_get_agent_card',
|
||||
name: 'A2A Get Agent Card',
|
||||
description: 'Fetch the Agent Card (discovery document) for an A2A agent.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication (if required)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: A2AGetAgentCardParams) => params.agentUrl,
|
||||
method: 'GET',
|
||||
headers: (params: A2AGetAgentCardParams) => {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
if (params.apiKey) {
|
||||
headers.Authorization = `Bearer ${params.apiKey}`
|
||||
}
|
||||
return headers
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
name: '',
|
||||
url: '',
|
||||
version: '',
|
||||
},
|
||||
error: `Failed to fetch agent card: ${response.status} ${response.statusText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const agentCard = (await response.json()) as AgentCard
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
name: agentCard.name,
|
||||
description: agentCard.description,
|
||||
url: agentCard.url,
|
||||
version: agentCard.version,
|
||||
capabilities: agentCard.capabilities,
|
||||
skills: agentCard.skills,
|
||||
authentication: agentCard.authentication,
|
||||
defaultInputModes: agentCard.defaultInputModes,
|
||||
defaultOutputModes: agentCard.defaultOutputModes,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing Agent Card response:', error)
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
name: '',
|
||||
url: '',
|
||||
version: '',
|
||||
},
|
||||
error: error instanceof Error ? error.message : 'Failed to parse Agent Card',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Agent name',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Agent description',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Agent endpoint URL',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
description: 'Agent version',
|
||||
},
|
||||
capabilities: {
|
||||
type: 'object',
|
||||
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
|
||||
},
|
||||
skills: {
|
||||
type: 'array',
|
||||
description: 'Skills the agent can perform',
|
||||
},
|
||||
authentication: {
|
||||
type: 'object',
|
||||
description: 'Supported authentication schemes',
|
||||
},
|
||||
defaultInputModes: {
|
||||
type: 'array',
|
||||
description: 'Default input modes (text, file, data)',
|
||||
},
|
||||
defaultOutputModes: {
|
||||
type: 'array',
|
||||
description: 'Default output modes (text, file, data)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* A2A Get Task Tool
|
||||
*
|
||||
* Query the status of an existing A2A task.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { A2A_METHODS } from '@/lib/a2a/constants'
|
||||
import type { Task } from '@/lib/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
|
||||
|
||||
const logger = createLogger('A2AGetTaskTool')
|
||||
|
||||
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
|
||||
id: 'a2a_get_task',
|
||||
name: 'A2A Get Task',
|
||||
description: 'Query the status of an existing A2A task.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to query',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
historyLength: {
|
||||
type: 'number',
|
||||
description: 'Number of history messages to include',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: A2AGetTaskParams) => params.agentUrl,
|
||||
method: 'POST',
|
||||
headers: (params: A2AGetTaskParams) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (params.apiKey) {
|
||||
headers.Authorization = `Bearer ${params.apiKey}`
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params: A2AGetTaskParams) => ({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now().toString(),
|
||||
method: A2A_METHODS.TASKS_GET,
|
||||
params: {
|
||||
id: params.taskId,
|
||||
historyLength: params.historyLength,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
try {
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
taskId: '',
|
||||
state: 'failed',
|
||||
},
|
||||
error: result.error.message || 'A2A request failed',
|
||||
}
|
||||
}
|
||||
|
||||
const task = result.result as Task
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
taskId: task.id,
|
||||
contextId: task.contextId,
|
||||
state: task.status.state,
|
||||
artifacts: task.artifacts,
|
||||
history: task.history,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing A2A response:', error)
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
taskId: '',
|
||||
state: 'failed',
|
||||
},
|
||||
error: error instanceof Error ? error.message : 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Output artifacts',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Message history',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* A2A Tools
|
||||
*
|
||||
* Tools for interacting with external A2A-compatible agents.
|
||||
*/
|
||||
|
||||
export * from './cancel_task'
|
||||
export * from './get_agent_card'
|
||||
export * from './get_task'
|
||||
export * from './send_task'
|
||||
export * from './types'
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* A2A Send Task Tool
|
||||
*
|
||||
* Send a task to an external A2A-compatible agent.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { A2A_METHODS } from '@/lib/a2a/constants'
|
||||
import type { Task, TaskMessage } from '@/lib/a2a/types'
|
||||
import { extractTextContent } from '@/lib/a2a/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ASendTaskParams, A2ASendTaskResponse } from './types'
|
||||
|
||||
const logger = createLogger('A2ASendTaskTool')
|
||||
|
||||
export const a2aSendTaskTool: ToolConfig<A2ASendTaskParams, A2ASendTaskResponse> = {
|
||||
id: 'a2a_send_task',
|
||||
name: 'A2A Send Task',
|
||||
description: 'Send a message to an external A2A-compatible agent.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Message to send to the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for continuing an existing conversation',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: A2ASendTaskParams) => params.agentUrl,
|
||||
method: 'POST',
|
||||
headers: (params: A2ASendTaskParams) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (params.apiKey) {
|
||||
headers.Authorization = `Bearer ${params.apiKey}`
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params: A2ASendTaskParams) => {
|
||||
const userMessage: TaskMessage = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: params.message }],
|
||||
}
|
||||
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now().toString(),
|
||||
method: A2A_METHODS.TASKS_SEND,
|
||||
params: {
|
||||
id: params.taskId,
|
||||
contextId: params.contextId,
|
||||
message: userMessage,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
try {
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
content: result.error.message || 'A2A request failed',
|
||||
taskId: '',
|
||||
state: 'failed',
|
||||
},
|
||||
error: result.error.message || 'A2A request failed',
|
||||
}
|
||||
}
|
||||
|
||||
const task = result.result as Task
|
||||
|
||||
// Extract content from the last agent message
|
||||
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
|
||||
|
||||
const content = lastAgentMessage ? extractTextContent(lastAgentMessage) : ''
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content,
|
||||
taskId: task.id,
|
||||
contextId: task.contextId,
|
||||
state: task.status.state,
|
||||
artifacts: task.artifacts,
|
||||
history: task.history,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing A2A response:', error)
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
content: error instanceof Error ? error.message : 'Failed to parse response',
|
||||
taskId: '',
|
||||
state: 'failed',
|
||||
},
|
||||
error: error instanceof Error ? error.message : 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The text response from the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for follow-up interactions',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Structured output artifacts',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Full message history',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* A2A Tool Types (v0.2.6)
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentAuthentication,
|
||||
AgentCapabilities,
|
||||
AgentSkill,
|
||||
Artifact,
|
||||
InputMode,
|
||||
OutputMode,
|
||||
TaskMessage,
|
||||
TaskState,
|
||||
} from '@/lib/a2a/types'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface A2AGetAgentCardParams {
|
||||
/** A2A agent endpoint URL */
|
||||
agentUrl: string
|
||||
/** API key for authentication (if required) */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2AGetAgentCardResponse extends ToolResponse {
|
||||
output: {
|
||||
/** Agent name */
|
||||
name: string
|
||||
/** Agent description */
|
||||
description?: string
|
||||
/** Agent endpoint URL */
|
||||
url: string
|
||||
/** Agent version */
|
||||
version: string
|
||||
/** Agent capabilities */
|
||||
capabilities?: AgentCapabilities
|
||||
/** Skills the agent can perform */
|
||||
skills?: AgentSkill[]
|
||||
/** Supported authentication schemes */
|
||||
authentication?: AgentAuthentication
|
||||
/** Default input modes */
|
||||
defaultInputModes?: InputMode[]
|
||||
/** Default output modes */
|
||||
defaultOutputModes?: OutputMode[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2ASendTaskParams {
|
||||
/** A2A agent endpoint URL */
|
||||
agentUrl: string
|
||||
/** Message to send */
|
||||
message: string
|
||||
/** Task ID (for continuing a task) */
|
||||
taskId?: string
|
||||
/** Context ID (for multi-turn conversations) */
|
||||
contextId?: string
|
||||
/** API key for authentication */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2ASendTaskResponse extends ToolResponse {
|
||||
output: {
|
||||
/** Response content text */
|
||||
content: string
|
||||
/** Task ID */
|
||||
taskId: string
|
||||
/** Context ID */
|
||||
contextId?: string
|
||||
/** Task state */
|
||||
state: TaskState
|
||||
/** Output artifacts */
|
||||
artifacts?: Artifact[]
|
||||
/** Message history */
|
||||
history?: TaskMessage[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2AGetTaskParams {
|
||||
/** A2A agent endpoint URL */
|
||||
agentUrl: string
|
||||
/** Task ID to query */
|
||||
taskId: string
|
||||
/** API key for authentication */
|
||||
apiKey?: string
|
||||
/** Number of history messages to include */
|
||||
historyLength?: number
|
||||
}
|
||||
|
||||
export interface A2AGetTaskResponse extends ToolResponse {
|
||||
output: {
|
||||
/** Task ID */
|
||||
taskId: string
|
||||
/** Context ID */
|
||||
contextId?: string
|
||||
/** Task state */
|
||||
state: TaskState
|
||||
/** Output artifacts */
|
||||
artifacts?: Artifact[]
|
||||
/** Message history */
|
||||
history?: TaskMessage[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2ACancelTaskParams {
|
||||
/** A2A agent endpoint URL */
|
||||
agentUrl: string
|
||||
/** Task ID to cancel */
|
||||
taskId: string
|
||||
/** API key for authentication */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2ACancelTaskResponse extends ToolResponse {
|
||||
output: {
|
||||
/** Whether cancellation was successful */
|
||||
cancelled: boolean
|
||||
/** Task state after cancellation */
|
||||
state: TaskState
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Webhook endpoint URL (must respond 2xx)',
|
||||
},
|
||||
hookType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Type of webhook: "recording_added" or "upload_status"',
|
||||
},
|
||||
filterBeforeDatetime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -81,6 +87,7 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
|
||||
body: (params) => {
|
||||
const body: Record<string, any> = {
|
||||
hook_url: params.hookUrl,
|
||||
hook_type: params.hookType,
|
||||
}
|
||||
|
||||
const filter: Record<string, any> = {}
|
||||
@@ -147,6 +154,10 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
|
||||
type: 'string',
|
||||
description: 'The webhook URL',
|
||||
},
|
||||
hook_type: {
|
||||
type: 'string',
|
||||
description: 'Type of hook: recording_added or upload_status',
|
||||
},
|
||||
filter: {
|
||||
type: 'object',
|
||||
description: 'Applied filters',
|
||||
|
||||
@@ -51,6 +51,7 @@ export const grainListHooksTool: ToolConfig<GrainListHooksParams, GrainListHooks
|
||||
id: { type: 'string', description: 'Hook UUID' },
|
||||
enabled: { type: 'boolean', description: 'Whether hook is active' },
|
||||
hook_url: { type: 'string', description: 'Webhook URL' },
|
||||
hook_type: { type: 'string', description: 'Type: recording_added or upload_status' },
|
||||
filter: { type: 'object', description: 'Applied filters' },
|
||||
include: { type: 'object', description: 'Included fields' },
|
||||
inserted_at: { type: 'string', description: 'Creation timestamp' },
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface GrainHook {
|
||||
id: string
|
||||
enabled: boolean
|
||||
hook_url: string
|
||||
hook_type: 'recording_added' | 'upload_status'
|
||||
filter: GrainRecordingFilter
|
||||
include: GrainRecordingInclude
|
||||
inserted_at: string
|
||||
@@ -192,6 +193,7 @@ export interface GrainListMeetingTypesResponse extends ToolResponse {
|
||||
export interface GrainCreateHookParams {
|
||||
apiKey: string
|
||||
hookUrl: string
|
||||
hookType: 'recording_added' | 'upload_status'
|
||||
filterBeforeDatetime?: string
|
||||
filterAfterDatetime?: string
|
||||
filterParticipantScope?: 'internal' | 'external'
|
||||
|
||||
@@ -19,12 +19,6 @@ export const linearCreateProjectLabelTool: ToolConfig<
|
||||
},
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'The project for this label',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -71,7 +65,6 @@ export const linearCreateProjectLabelTool: ToolConfig<
|
||||
},
|
||||
body: (params) => {
|
||||
const input: Record<string, any> = {
|
||||
projectId: params.projectId,
|
||||
name: params.name,
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user