mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
9 Commits
improvemen
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a037bc0d7 | ||
|
|
9502227fd4 | ||
|
|
13981549d1 | ||
|
|
554dcdf062 | ||
|
|
6b28742b68 | ||
|
|
e5c95093f6 | ||
|
|
b87af80bff | ||
|
|
c2180bf8a0 | ||
|
|
fdac4314d2 |
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>
|
||||
@@ -48,40 +48,40 @@ The model breakdown shows:
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tab>
|
||||
**Hosted Models** - Sim provides API keys with a 2x pricing multiplier:
|
||||
**Hosted Models** - Sim provides API keys with a 1.4x pricing multiplier for Agent blocks:
|
||||
|
||||
**OpenAI**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
|
||||
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
|
||||
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
|
||||
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
|
||||
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
|
||||
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
|
||||
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
|
||||
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
|
||||
|
||||
**Anthropic**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
|
||||
|
||||
**Google**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
|
||||
|
||||
*The 2x multiplier covers infrastructure and API management costs.*
|
||||
*The 1.4x multiplier covers infrastructure and API management costs.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -473,6 +473,7 @@ export function Chat() {
|
||||
/**
|
||||
* Processes streaming response from workflow execution
|
||||
* Reads the stream chunk by chunk and updates the message content in real-time
|
||||
* When the final event arrives, extracts any additional selected outputs (model, tokens, toolCalls)
|
||||
* @param stream - ReadableStream containing the workflow execution response
|
||||
* @param responseMessageId - ID of the message to update with streamed content
|
||||
*/
|
||||
@@ -529,6 +530,35 @@ export function Chat() {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
selectedOutputs.length > 0 &&
|
||||
'logs' in result &&
|
||||
Array.isArray(result.logs) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
const additionalOutputs: string[] = []
|
||||
|
||||
for (const outputId of selectedOutputs) {
|
||||
const blockId = extractBlockIdFromOutputId(outputId)
|
||||
const path = extractPathFromOutputId(outputId, blockId)
|
||||
|
||||
if (path === 'content') continue
|
||||
|
||||
const outputValue = extractOutputFromLogs(result.logs as BlockLog[], outputId)
|
||||
if (outputValue !== undefined) {
|
||||
const formattedValue =
|
||||
typeof outputValue === 'string' ? outputValue : JSON.stringify(outputValue)
|
||||
if (formattedValue) {
|
||||
additionalOutputs.push(`**${path}:** ${formattedValue}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalOutputs.length > 0) {
|
||||
appendMessageContent(responseMessageId, `\n\n${additionalOutputs.join('\n\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
finalizeMessageStream(responseMessageId)
|
||||
} else if (contentChunk) {
|
||||
accumulatedContent += contentChunk
|
||||
@@ -552,7 +582,7 @@ export function Chat() {
|
||||
focusInput(100)
|
||||
}
|
||||
},
|
||||
[appendMessageContent, finalizeMessageStream, focusInput]
|
||||
[appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -564,7 +594,6 @@ export function Chat() {
|
||||
if (!result || !activeWorkflowId) return
|
||||
if (typeof result !== 'object') return
|
||||
|
||||
// Handle streaming response
|
||||
if ('stream' in result && result.stream instanceof ReadableStream) {
|
||||
const responseMessageId = crypto.randomUUID()
|
||||
addMessage({
|
||||
@@ -578,7 +607,6 @@ export function Chat() {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle success with logs
|
||||
if ('success' in result && result.success && 'logs' in result && Array.isArray(result.logs)) {
|
||||
selectedOutputs
|
||||
.map((outputId) => extractOutputFromLogs(result.logs as BlockLog[], outputId))
|
||||
@@ -596,7 +624,6 @@ export function Chat() {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle error response
|
||||
if ('success' in result && !result.success) {
|
||||
const errorMessage =
|
||||
'error' in result && typeof result.error === 'string'
|
||||
@@ -622,7 +649,6 @@ export function Chat() {
|
||||
|
||||
const sentMessage = chatMessage.trim()
|
||||
|
||||
// Update prompt history (only if new unique message)
|
||||
if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) {
|
||||
setPromptHistory((prev) => [...prev, sentMessage])
|
||||
}
|
||||
@@ -631,10 +657,8 @@ export function Chat() {
|
||||
const conversationId = getConversationId(activeWorkflowId)
|
||||
|
||||
try {
|
||||
// Process file attachments
|
||||
const attachmentsWithData = await processFileAttachments(chatFiles)
|
||||
|
||||
// Add user message
|
||||
const messageContent =
|
||||
sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : '')
|
||||
addMessage({
|
||||
@@ -644,7 +668,6 @@ export function Chat() {
|
||||
attachments: attachmentsWithData,
|
||||
})
|
||||
|
||||
// Prepare workflow input
|
||||
const workflowInput: {
|
||||
input: string
|
||||
conversationId: string
|
||||
@@ -667,13 +690,11 @@ export function Chat() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear input and files
|
||||
setChatMessage('')
|
||||
clearFiles()
|
||||
clearErrors()
|
||||
focusInput(10)
|
||||
|
||||
// Execute workflow
|
||||
const result = await handleRunWorkflow(workflowInput)
|
||||
handleWorkflowResponse(result)
|
||||
} catch (error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -885,6 +885,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
const activeBlocksSet = new Set<string>()
|
||||
const streamedContent = new Map<string, string>()
|
||||
const accumulatedBlockLogs: BlockLog[] = []
|
||||
|
||||
// Execute the workflow
|
||||
try {
|
||||
@@ -933,14 +934,30 @@ export function useWorkflowExecution() {
|
||||
|
||||
// Edges already tracked in onBlockStarted, no need to track again
|
||||
|
||||
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
|
||||
const endedAt = new Date().toISOString()
|
||||
|
||||
// Accumulate block log for the execution result
|
||||
accumulatedBlockLogs.push({
|
||||
blockId: data.blockId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
input: data.input || {},
|
||||
output: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
// Add to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: new Date(Date.now() - data.durationMs).toISOString(),
|
||||
endedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
endedAt,
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionId || uuidv4(),
|
||||
@@ -967,6 +984,24 @@ export function useWorkflowExecution() {
|
||||
|
||||
// Track failed block execution in run path
|
||||
setBlockRunStatus(data.blockId, 'error')
|
||||
|
||||
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
|
||||
const endedAt = new Date().toISOString()
|
||||
|
||||
// Accumulate block error log for the execution result
|
||||
accumulatedBlockLogs.push({
|
||||
blockId: data.blockId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
input: data.input || {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
// Add error to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
@@ -974,8 +1009,8 @@ export function useWorkflowExecution() {
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: new Date(Date.now() - data.durationMs).toISOString(),
|
||||
endedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
endedAt,
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionId || uuidv4(),
|
||||
@@ -1029,7 +1064,7 @@ export function useWorkflowExecution() {
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
},
|
||||
logs: [],
|
||||
logs: accumulatedBlockLogs,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1041,7 +1076,7 @@ export function useWorkflowExecution() {
|
||||
metadata: {
|
||||
duration: data.duration,
|
||||
},
|
||||
logs: [],
|
||||
logs: accumulatedBlockLogs,
|
||||
}
|
||||
|
||||
// Only add workflow-level error if no blocks have executed yet
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type {
|
||||
LinearCreateProjectLinkParams,
|
||||
LinearCreateProjectLinkResponse,
|
||||
} from '@/tools/linear/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const linearCreateProjectLinkTool: ToolConfig<
|
||||
LinearCreateProjectLinkParams,
|
||||
LinearCreateProjectLinkResponse
|
||||
> = {
|
||||
id: 'linear_create_project_link',
|
||||
name: 'Linear Create Project Link',
|
||||
description: 'Add an external link to a project in Linear',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'linear',
|
||||
},
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Project ID to add link to',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'URL of the external link',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Link label/title',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.linear.app/graphql',
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Missing access token for Linear API request')
|
||||
}
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
const input: Record<string, any> = {
|
||||
projectId: params.projectId,
|
||||
url: params.url,
|
||||
}
|
||||
|
||||
if (params.label != null && params.label !== '') input.label = params.label
|
||||
|
||||
return {
|
||||
query: `
|
||||
mutation CreateProjectLink($input: ProjectLinkCreateInput!) {
|
||||
projectLinkCreate(input: $input) {
|
||||
success
|
||||
projectLink {
|
||||
id
|
||||
url
|
||||
label
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.errors[0]?.message || 'Failed to create project link',
|
||||
output: {},
|
||||
}
|
||||
}
|
||||
|
||||
const result = data.data.projectLinkCreate
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Project link creation was not successful',
|
||||
output: {},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
link: result.projectLink,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
link: {
|
||||
type: 'object',
|
||||
description: 'The created project link',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Link ID' },
|
||||
url: { type: 'string', description: 'Link URL' },
|
||||
label: { type: 'string', description: 'Link label' },
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -19,24 +19,31 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
},
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'The project to create the status for',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Project status name',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Status type: "backlog", "planned", "started", "paused", "completed", or "canceled"',
|
||||
},
|
||||
color: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Status color (hex code)',
|
||||
},
|
||||
position: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Position in status list (e.g. 0, 1, 2...)',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -49,12 +56,6 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the status is indefinite',
|
||||
},
|
||||
position: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Position in status list',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -71,9 +72,10 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
},
|
||||
body: (params) => {
|
||||
const input: Record<string, any> = {
|
||||
projectId: params.projectId,
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
color: params.color,
|
||||
position: params.position,
|
||||
}
|
||||
|
||||
if (params.description != null && params.description !== '') {
|
||||
@@ -82,9 +84,6 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
if (params.indefinite != null) {
|
||||
input.indefinite = params.indefinite
|
||||
}
|
||||
if (params.position != null) {
|
||||
input.position = params.position
|
||||
}
|
||||
|
||||
return {
|
||||
query: `
|
||||
|
||||
@@ -16,7 +16,6 @@ import { linearCreateIssueRelationTool } from '@/tools/linear/create_issue_relat
|
||||
import { linearCreateLabelTool } from '@/tools/linear/create_label'
|
||||
import { linearCreateProjectTool } from '@/tools/linear/create_project'
|
||||
import { linearCreateProjectLabelTool } from '@/tools/linear/create_project_label'
|
||||
import { linearCreateProjectLinkTool } from '@/tools/linear/create_project_link'
|
||||
import { linearCreateProjectMilestoneTool } from '@/tools/linear/create_project_milestone'
|
||||
import { linearCreateProjectStatusTool } from '@/tools/linear/create_project_status'
|
||||
import { linearCreateProjectUpdateTool } from '@/tools/linear/create_project_update'
|
||||
@@ -138,7 +137,6 @@ export {
|
||||
linearListFavoritesTool,
|
||||
linearCreateProjectUpdateTool,
|
||||
linearListProjectUpdatesTool,
|
||||
linearCreateProjectLinkTool,
|
||||
linearListNotificationsTool,
|
||||
linearUpdateNotificationTool,
|
||||
linearCreateCustomerTool,
|
||||
|
||||
@@ -454,13 +454,6 @@ export interface LinearListProjectUpdatesParams {
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectLinkParams {
|
||||
projectId: string
|
||||
url: string
|
||||
label?: string
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export interface LinearListNotificationsParams {
|
||||
first?: number
|
||||
after?: string
|
||||
@@ -843,19 +836,6 @@ export interface LinearListProjectUpdatesResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinearProjectLink {
|
||||
id: string
|
||||
url: string
|
||||
label: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectLinkResponse extends ToolResponse {
|
||||
output: {
|
||||
link?: LinearProjectLink
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinearNotification {
|
||||
id: string
|
||||
type: string
|
||||
@@ -1205,7 +1185,6 @@ export interface LinearProjectLabel {
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectLabelParams {
|
||||
projectId: string
|
||||
name: string
|
||||
color?: string
|
||||
description?: string
|
||||
@@ -1358,12 +1337,12 @@ export interface LinearProjectStatus {
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectStatusParams {
|
||||
projectId: string
|
||||
name: string
|
||||
type: 'backlog' | 'planned' | 'started' | 'paused' | 'completed' | 'canceled'
|
||||
color: string
|
||||
position: number
|
||||
description?: string
|
||||
indefinite?: boolean
|
||||
position?: number
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
@@ -1468,7 +1447,6 @@ export type LinearResponse =
|
||||
| LinearListFavoritesResponse
|
||||
| LinearCreateProjectUpdateResponse
|
||||
| LinearListProjectUpdatesResponse
|
||||
| LinearCreateProjectLinkResponse
|
||||
| LinearListNotificationsResponse
|
||||
| LinearUpdateNotificationResponse
|
||||
| LinearCreateCustomerResponse
|
||||
|
||||
@@ -567,7 +567,6 @@ import {
|
||||
linearCreateIssueTool,
|
||||
linearCreateLabelTool,
|
||||
linearCreateProjectLabelTool,
|
||||
linearCreateProjectLinkTool,
|
||||
linearCreateProjectMilestoneTool,
|
||||
linearCreateProjectStatusTool,
|
||||
linearCreateProjectTool,
|
||||
@@ -2187,7 +2186,6 @@ export const tools: Record<string, ToolConfig> = {
|
||||
linear_list_favorites: linearListFavoritesTool,
|
||||
linear_create_project_update: linearCreateProjectUpdateTool,
|
||||
linear_list_project_updates: linearListProjectUpdatesTool,
|
||||
linear_create_project_link: linearCreateProjectLinkTool,
|
||||
linear_list_notifications: linearListNotificationsTool,
|
||||
linear_update_notification: linearUpdateNotificationTool,
|
||||
linear_create_customer: linearCreateCustomerTool,
|
||||
|
||||
2
bun.lock
2
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
@@ -249,6 +250,7 @@
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"postgres": "^3.4.5",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -32,6 +32,9 @@ RUN addgroup -g 1001 -S nodejs && \
|
||||
# Copy only the necessary files from deps (cached if dependencies don't change)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Copy root package.json for workspace resolution
|
||||
COPY --chown=nextjs:nodejs package.json ./package.json
|
||||
|
||||
# Copy package configuration files (needed for migrations)
|
||||
COPY --chown=nextjs:nodejs packages/db/drizzle.config.ts ./packages/db/drizzle.config.ts
|
||||
|
||||
|
||||
2
packages/db/migrations/0136_pretty_jack_flag.sql
Normal file
2
packages/db/migrations/0136_pretty_jack_flag.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP INDEX "member_user_id_idx";--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "member_user_id_unique" ON "member" USING btree ("user_id");
|
||||
9333
packages/db/migrations/meta/0136_snapshot.json
Normal file
9333
packages/db/migrations/meta/0136_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -946,6 +946,13 @@
|
||||
"when": 1767737974016,
|
||||
"tag": "0135_stormy_puff_adder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 136,
|
||||
"version": "7",
|
||||
"when": 1767905804764,
|
||||
"tag": "0136_pretty_jack_flag",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"postgres": "^3.4.5",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -824,7 +824,7 @@ export const member = pgTable(
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('member_user_id_idx').on(table.userId),
|
||||
userIdUnique: uniqueIndex('member_user_id_unique').on(table.userId), // Users can only belong to one org
|
||||
organizationIdIdx: index('member_organization_id_idx').on(table.organizationId),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* SSO provider records into the database, following the exact same logic
|
||||
* as Better Auth's registerSSOProvider endpoint.
|
||||
*
|
||||
* Usage: bun run packages/db/register-sso-provider.ts
|
||||
* Usage: bun run packages/db/scripts/register-sso-provider.ts
|
||||
*
|
||||
* Required Environment Variables:
|
||||
* SSO_ENABLED=true
|
||||
|
||||
Reference in New Issue
Block a user