mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
18 Commits
fix/google
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47eb060311 | ||
|
|
fd76e98f0e | ||
|
|
1dbd16115f | ||
|
|
38e827b61a | ||
|
|
1f5e8a41f8 | ||
|
|
796f73ee01 | ||
|
|
d3d6012d5c | ||
|
|
860610b4c2 | ||
|
|
05bbf34265 | ||
|
|
753600ed60 | ||
|
|
4da43d937c | ||
|
|
9502227fd4 | ||
|
|
13981549d1 | ||
|
|
554dcdf062 | ||
|
|
6b28742b68 | ||
|
|
e5c95093f6 | ||
|
|
b87af80bff | ||
|
|
c2180bf8a0 |
@@ -4575,3 +4575,22 @@ export function FirefliesIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
|
||||
<defs>
|
||||
<linearGradient id='bedrock_gradient' x1='80%' x2='20%' y1='20%' y2='80%'>
|
||||
<stop offset='0%' stopColor='#6350FB' />
|
||||
<stop offset='50%' stopColor='#3D8FFF' />
|
||||
<stop offset='100%' stopColor='#9AD8F8' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d='M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z'
|
||||
fill='url(#bedrock_gradient)'
|
||||
fillRule='nonzero'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -49,40 +49,40 @@ Die Modellaufschlüsselung zeigt:
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tab>
|
||||
**Gehostete Modelle** - Sim stellt API-Schlüssel mit einem 2-fachen Preismultiplikator bereit:
|
||||
**Hosted Models** - Sim bietet API-Schlüssel mit einem 1,4-fachen Preismultiplikator für Agent-Blöcke:
|
||||
|
||||
**OpenAI**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
|
||||
*Der 2x-Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.*
|
||||
*Der 1,4-fache Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
The Router block uses AI to intelligently route workflows based on content analysis. Unlike Condition blocks that use simple rules, Routers understand context and intent.
|
||||
The Router block uses AI to intelligently route workflows based on content analysis. Unlike Condition blocks that use simple rules, Routers understand context and intent. Each route you define creates a separate output port, allowing you to connect different paths to different downstream blocks.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/router.png"
|
||||
alt="Router Block with Multiple Paths"
|
||||
alt="Router Block with Multiple Route Ports"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
@@ -32,21 +32,23 @@ The Router block uses AI to intelligently route workflows based on content analy
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Content/Prompt
|
||||
### Context
|
||||
|
||||
The content or prompt that the Router will analyze to make routing decisions. This can be:
|
||||
The context that the Router will analyze to make routing decisions. This is the input data that gets evaluated against your route descriptions. It can be:
|
||||
|
||||
- A direct user query or input
|
||||
- Output from a previous block
|
||||
- A system-generated message
|
||||
- Any text content that needs intelligent routing
|
||||
|
||||
### Target Blocks
|
||||
### Routes
|
||||
|
||||
The possible destination blocks that the Router can select from. The Router will automatically detect connected blocks, but you can also:
|
||||
Define the possible paths that the Router can take. Each route consists of:
|
||||
|
||||
- Customize the descriptions of target blocks to improve routing accuracy
|
||||
- Specify routing criteria for each target block
|
||||
- Exclude certain blocks from being considered as routing targets
|
||||
- **Route Title**: A name for the route (e.g., "Sales", "Support", "Technical")
|
||||
- **Route Description**: A clear description of when this route should be selected (e.g., "Route here when the query is about pricing, purchasing, or sales inquiries")
|
||||
|
||||
Each route you add creates a **separate output port** on the Router block. Connect each port to the appropriate downstream block for that route.
|
||||
|
||||
### Model Selection
|
||||
|
||||
@@ -66,8 +68,9 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
|
||||
## Outputs
|
||||
|
||||
- **`<router.prompt>`**: Summary of the routing prompt
|
||||
- **`<router.selected_path>`**: Chosen destination block
|
||||
- **`<router.context>`**: The context that was analyzed
|
||||
- **`<router.selectedRoute>`**: The ID of the selected route
|
||||
- **`<router.selected_path>`**: Details of the chosen destination block
|
||||
- **`<router.tokens>`**: Token usage statistics
|
||||
- **`<router.cost>`**: Estimated routing cost
|
||||
- **`<router.model>`**: Model used for decision-making
|
||||
@@ -75,26 +78,36 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
## Example Use Cases
|
||||
|
||||
**Customer Support Triage** - Route tickets to specialized departments
|
||||
|
||||
```
|
||||
Input (Ticket) → Router → Agent (Engineering) or Agent (Finance)
|
||||
Input (Ticket) → Router
|
||||
├── [Sales Route] → Agent (Sales Team)
|
||||
├── [Technical Route] → Agent (Engineering)
|
||||
└── [Billing Route] → Agent (Finance)
|
||||
```
|
||||
|
||||
**Content Classification** - Classify and route user-generated content
|
||||
|
||||
```
|
||||
Input (Feedback) → Router → Workflow (Product) or Workflow (Technical)
|
||||
Input (Feedback) → Router
|
||||
├── [Product Feedback] → Workflow (Product Team)
|
||||
└── [Bug Report] → Workflow (Technical Team)
|
||||
```
|
||||
|
||||
**Lead Qualification** - Route leads based on qualification criteria
|
||||
```
|
||||
Input (Lead) → Router → Agent (Enterprise Sales) or Workflow (Self-serve)
|
||||
```
|
||||
|
||||
```
|
||||
Input (Lead) → Router
|
||||
├── [Enterprise] → Agent (Enterprise Sales)
|
||||
└── [Self-serve] → Workflow (Automated Onboarding)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Provide clear target descriptions**: Help the Router understand when to select each destination with specific, detailed descriptions
|
||||
- **Use specific routing criteria**: Define clear conditions and examples for each path to improve accuracy
|
||||
- **Implement fallback paths**: Connect a default destination for when no specific path is appropriate
|
||||
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content
|
||||
- **Monitor routing performance**: Review routing decisions regularly and refine criteria based on actual usage patterns
|
||||
- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions
|
||||
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
|
||||
- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions.
|
||||
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
|
||||
- **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability.
|
||||
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content.
|
||||
- **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns.
|
||||
- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions.
|
||||
|
||||
120
apps/docs/content/docs/en/enterprise/index.mdx
Normal file
120
apps/docs/content/docs/en/enterprise/index.mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Enterprise
|
||||
description: Enterprise features for business organizations
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
|
||||
|
||||
---
|
||||
|
||||
## Access Control
|
||||
|
||||
Define permission groups to control what features and integrations team members can use.
|
||||
|
||||
### Features
|
||||
|
||||
- **Allowed Model Providers** - Restrict which AI providers users can access (OpenAI, Anthropic, Google, etc.)
|
||||
- **Allowed Blocks** - Control which workflow blocks are available
|
||||
- **Platform Settings** - Hide Knowledge Base, disable MCP tools, or disable custom tools
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **Access Control** in your workspace
|
||||
2. Create a permission group with your desired restrictions
|
||||
3. Add team members to the permission group
|
||||
|
||||
<Callout type="info">
|
||||
Users not assigned to any permission group have full access. Permission restrictions are enforced at both UI and execution time.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## 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 Configuration
|
||||
|
||||
For self-hosted deployments, enterprise features can be enabled via environment variables without requiring billing.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ORGANIZATIONS_ENABLED`, `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | Enable team/organization management |
|
||||
| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups for access restrictions |
|
||||
| `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 |
|
||||
|
||||
### Organization Management
|
||||
|
||||
When billing is disabled, use the Admin API to manage organizations:
|
||||
|
||||
```bash
|
||||
# Create an organization
|
||||
curl -X POST https://your-instance/api/v1/admin/organizations \
|
||||
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Organization", "ownerId": "user-id-here"}'
|
||||
|
||||
# Add a member
|
||||
curl -X POST https://your-instance/api/v1/admin/organizations/{orgId}/members \
|
||||
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"userId": "user-id-here", "role": "admin"}'
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Enabling `ACCESS_CONTROL_ENABLED` automatically enables organizations, as access control requires organization membership.
|
||||
- BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@ Integrate Google Drive into the workflow. Can create, upload, and list files.
|
||||
|
||||
### `google_drive_upload`
|
||||
|
||||
Upload a file to Google Drive
|
||||
Upload a file to Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -65,11 +65,11 @@ Upload a file to Google Drive
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | Uploaded file metadata including ID, name, and links |
|
||||
| `file` | object | Complete uploaded file metadata from Google Drive |
|
||||
|
||||
### `google_drive_create_folder`
|
||||
|
||||
Create a new folder in Google Drive
|
||||
Create a new folder in Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -83,11 +83,11 @@ Create a new folder in Google Drive
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | Created folder metadata including ID, name, and parent information |
|
||||
| `file` | object | Complete created folder metadata from Google Drive |
|
||||
|
||||
### `google_drive_download`
|
||||
|
||||
Download a file from Google Drive (exports Google Workspace files automatically)
|
||||
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -96,16 +96,17 @@ Download a file from Google Drive (exports Google Workspace files automatically)
|
||||
| `fileId` | string | Yes | The ID of the file to download |
|
||||
| `mimeType` | string | No | The MIME type to export Google Workspace files to \(optional\) |
|
||||
| `fileName` | string | No | Optional filename override |
|
||||
| `includeRevisions` | boolean | No | Whether to include revision history in the metadata \(default: true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `file` | object | Downloaded file stored in execution files |
|
||||
|
||||
### `google_drive_list`
|
||||
|
||||
List files and folders in Google Drive
|
||||
List files and folders in Google Drive with complete metadata
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -121,7 +122,7 @@ List files and folders in Google Drive
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | json | Array of file metadata objects from the specified folder |
|
||||
| `files` | array | Array of file metadata objects from Google Drive |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ Create a webhook to receive recording events
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
|
||||
| `hookUrl` | string | Yes | Webhook endpoint URL \(must respond 2xx\) |
|
||||
| `hookType` | string | Yes | Type of webhook: "recording_added" or "upload_status" |
|
||||
| `filterBeforeDatetime` | string | No | Filter: recordings before this date |
|
||||
| `filterAfterDatetime` | string | No | Filter: recordings after this date |
|
||||
| `filterParticipantScope` | string | No | Filter: "internal" or "external" |
|
||||
@@ -178,6 +179,7 @@ Create a webhook to receive recording events
|
||||
| `id` | string | Hook UUID |
|
||||
| `enabled` | boolean | Whether hook is active |
|
||||
| `hook_url` | string | The webhook URL |
|
||||
| `hook_type` | string | Type of hook: recording_added or upload_status |
|
||||
| `filter` | object | Applied filters |
|
||||
| `include` | object | Included fields |
|
||||
| `inserted_at` | string | ISO8601 creation timestamp |
|
||||
|
||||
@@ -851,24 +851,6 @@ List all status updates for a project in Linear
|
||||
| --------- | ---- | ----------- |
|
||||
| `updates` | array | Array of project updates |
|
||||
|
||||
### `linear_create_project_link`
|
||||
|
||||
Add an external link to a project in Linear
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | Project ID to add link to |
|
||||
| `url` | string | Yes | URL of the external link |
|
||||
| `label` | string | No | Link label/title |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `link` | object | The created project link |
|
||||
|
||||
### `linear_list_notifications`
|
||||
|
||||
List notifications for the current user in Linear
|
||||
@@ -1246,7 +1228,6 @@ Create a new project label in Linear
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | The project for this label |
|
||||
| `name` | string | Yes | Project label name |
|
||||
| `color` | string | No | Label color \(hex code\) |
|
||||
| `description` | string | No | Label description |
|
||||
@@ -1424,12 +1405,12 @@ Create a new project status in Linear
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | The project to create the status for |
|
||||
| `name` | string | Yes | Project status name |
|
||||
| `type` | string | Yes | Status type: "backlog", "planned", "started", "paused", "completed", or "canceled" |
|
||||
| `color` | string | Yes | Status color \(hex code\) |
|
||||
| `position` | number | Yes | Position in status list \(e.g. 0, 1, 2...\) |
|
||||
| `description` | string | No | Status description |
|
||||
| `indefinite` | boolean | No | Whether the status is indefinite |
|
||||
| `position` | number | No | Position in status list |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -79,30 +79,6 @@ Capture multiple events at once in PostHog. Use this for bulk event ingestion to
|
||||
| `status` | string | Status message indicating whether the batch was captured successfully |
|
||||
| `eventsProcessed` | number | Number of events processed in the batch |
|
||||
|
||||
### `posthog_list_events`
|
||||
|
||||
List events in PostHog. Note: This endpoint is deprecated but kept for backwards compatibility. For production use, prefer the Query endpoint with HogQL.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
|
||||
| `region` | string | No | PostHog region: us \(default\) or eu |
|
||||
| `projectId` | string | Yes | PostHog Project ID |
|
||||
| `limit` | number | No | Number of events to return \(default: 100, max: 100\) |
|
||||
| `offset` | number | No | Number of events to skip for pagination |
|
||||
| `event` | string | No | Filter by specific event name |
|
||||
| `distinctId` | string | No | Filter by specific distinct_id |
|
||||
| `before` | string | No | ISO 8601 timestamp - only return events before this time |
|
||||
| `after` | string | No | ISO 8601 timestamp - only return events after this time |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | array | List of events with their properties and metadata |
|
||||
|
||||
### `posthog_list_persons`
|
||||
|
||||
List persons (users) in PostHog. Returns user profiles with their properties and distinct IDs.
|
||||
|
||||
@@ -53,6 +53,9 @@ Send a chat completion request to any supported LLM provider
|
||||
| `vertexProject` | string | No | Google Cloud project ID for Vertex AI |
|
||||
| `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) |
|
||||
| `vertexCredential` | string | No | Google Cloud OAuth credential ID for Vertex AI |
|
||||
| `bedrockAccessKeyId` | string | No | AWS Access Key ID for Bedrock |
|
||||
| `bedrockSecretKey` | string | No | AWS Secret Access Key for Bedrock |
|
||||
| `bedrockRegion` | string | No | AWS region for Bedrock \(defaults to us-east-1\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
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>
|
||||
@@ -49,40 +49,40 @@ El desglose del modelo muestra:
|
||||
|
||||
<Tabs items={['Modelos alojados', 'Trae tu propia clave API']}>
|
||||
<Tab>
|
||||
**Modelos alojados** - Sim proporciona claves API con un multiplicador de precio de 2x:
|
||||
**Modelos alojados** - Sim proporciona claves API con un multiplicador de precios de 1.4x para bloques de agente:
|
||||
|
||||
**OpenAI**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
|
||||
*El multiplicador 2x cubre los costos de infraestructura y gestión de API.*
|
||||
*El multiplicador de 1.4x cubre los costos de infraestructura y gestión de API.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
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>
|
||||
@@ -49,40 +49,40 @@ La répartition des modèles montre :
|
||||
|
||||
<Tabs items={['Modèles hébergés', 'Apportez votre propre clé API']}>
|
||||
<Tab>
|
||||
**Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 2x :
|
||||
**Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 1,4x pour les blocs Agent :
|
||||
|
||||
**OpenAI**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 $ |
|
||||
|
||||
*Le multiplicateur 2x couvre les coûts d'infrastructure et de gestion des API.*
|
||||
*Le multiplicateur de 1,4x couvre les coûts d'infrastructure et de gestion des API.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
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>
|
||||
@@ -47,42 +47,42 @@ AIブロックを使用するワークフローでは、ログで詳細なコス
|
||||
|
||||
## 料金オプション
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tabs items={['ホステッドモデル', '独自のAPIキーを使用']}>
|
||||
<Tab>
|
||||
**ホステッドモデル** - Simは2倍の価格乗数でAPIキーを提供します:
|
||||
**ホステッドモデル** - Simは、エージェントブロック用に1.4倍の価格乗数を適用したAPIキーを提供します:
|
||||
|
||||
**OpenAI**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
|
||||
*2倍の乗数は、インフラストラクチャとAPI管理コストをカバーします。*
|
||||
*1.4倍の乗数は、インフラストラクチャとAPI管理のコストをカバーします。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
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>
|
||||
@@ -47,42 +47,42 @@ totalCost = baseExecutionCharge + modelCost
|
||||
|
||||
## 定价选项
|
||||
|
||||
<Tabs items={[ '托管模型', '自带 API 密钥' ]}>
|
||||
<Tabs items={['托管模型', '自带 API Key']}>
|
||||
<Tab>
|
||||
**托管模型** - Sim 提供 API 密钥,价格为基础价格的 2 倍:
|
||||
**托管模型** - Sim 为 Agent 模块提供 API Key,价格乘以 1.4 倍:
|
||||
|
||||
**OpenAI**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
|
||||
*2 倍系数涵盖了基础设施和 API 管理成本。*
|
||||
*1.4 倍的系数涵盖了基础设施和 API 管理成本。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -4581,11 +4581,11 @@ checksums:
|
||||
content/10: d19c8c67f52eb08b6a49c0969a9c8b86
|
||||
content/11: 4024a36e0d9479ff3191fb9cd2b2e365
|
||||
content/12: 0396a1e5d9548207f56e6b6cae85a542
|
||||
content/13: 4bfdeac5ad21c75209dcdfde85aa52b0
|
||||
content/14: 35df9a16b866dbe4bb9fc1d7aee42711
|
||||
content/15: 135c044066cea8cc0e22f06d67754ec5
|
||||
content/16: 6882b91e30548d7d331388c26cf2e948
|
||||
content/17: 29aed7061148ae46fa6ec8bcbc857c3d
|
||||
content/13: 68f90237f86be125224c56a2643904a3
|
||||
content/14: e854781f0fbf6f397a3ac682e892a993
|
||||
content/15: 2340c44af715fb8ca58f43151515aae1
|
||||
content/16: fc7ae93bff492d80f4b6f16e762e05fa
|
||||
content/17: 8a46692d5df3fed9f94d59dfc3fb7e0a
|
||||
content/18: e0571c88ea5bcd4305a6f5772dcbed98
|
||||
content/19: 83fc31418ff454a5e06b290e3708ef32
|
||||
content/20: 4392b5939a6d5774fb080cad1ee1dbb8
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 11 KiB |
@@ -767,7 +767,7 @@ export default function PrivacyPolicy() {
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
|
||||
</ul>
|
||||
<p>We will respond to your request within a reasonable timeframe.</p>
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import posthog from 'posthog-js'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
|
||||
@@ -35,12 +36,15 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [data, setData] = useState<AppSession>(null)
|
||||
const [isPending, setIsPending] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
const loadSession = useCallback(async (bypassCache = false) => {
|
||||
try {
|
||||
setIsPending(true)
|
||||
setError(null)
|
||||
const res = await client.getSession()
|
||||
const res = bypassCache
|
||||
? await client.getSession({ query: { disableCookieCache: true } })
|
||||
: await client.getSession()
|
||||
setData(res?.data ?? null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
|
||||
@@ -50,8 +54,25 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSession()
|
||||
}, [loadSession])
|
||||
// Check if user was redirected after plan upgrade
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const wasUpgraded = params.get('upgraded') === 'true'
|
||||
|
||||
if (wasUpgraded) {
|
||||
params.delete('upgraded')
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
}
|
||||
|
||||
loadSession(wasUpgraded).then(() => {
|
||||
if (wasUpgraded) {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
}
|
||||
})
|
||||
}, [loadSession, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || typeof posthog.identify !== 'function') {
|
||||
|
||||
@@ -42,6 +42,40 @@
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Flow selection box styling
|
||||
* Uses brand-secondary color for selection highlighting
|
||||
*/
|
||||
.react-flow__selection {
|
||||
background: rgba(51, 180, 255, 0.08) !important;
|
||||
border: 1px solid var(--brand-secondary) !important;
|
||||
}
|
||||
|
||||
.react-flow__nodesselection-rect,
|
||||
.react-flow__nodesselection {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected node ring indicator
|
||||
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
|
||||
*/
|
||||
.react-flow__node.selected > div > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-flow__node.selected > div > div::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1.75px var(--brand-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color tokens - single source of truth for all colors
|
||||
* Light mode: Warm theme
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -253,7 +253,7 @@ export async function POST(
|
||||
userId: deployment.userId,
|
||||
workspaceId,
|
||||
isDeployed: workflowRecord?.isDeployed ?? false,
|
||||
variables: workflowRecord?.variables || {},
|
||||
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,
|
||||
}
|
||||
|
||||
const stream = await createStreamingResponse({
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @deprecated This route is not currently in use
|
||||
* @remarks Kept for reference - may be removed in future cleanup
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('UpdateChatTitleAPI')
|
||||
|
||||
const UpdateTitleSchema = z.object({
|
||||
chatId: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = UpdateTitleSchema.parse(body)
|
||||
|
||||
// Update the chat title
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title: parsed.title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, parsed.chatId))
|
||||
|
||||
logger.info('Chat title updated', { chatId: parsed.chatId, title: parsed.title })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error updating chat title:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update chat title' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -21,7 +21,6 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
// Get user session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.email) {
|
||||
logger.warn(`[${requestId}] Unauthorized help request attempt`)
|
||||
@@ -30,20 +29,20 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const email = session.user.email
|
||||
|
||||
// Handle multipart form data
|
||||
const formData = await req.formData()
|
||||
|
||||
// Extract form fields
|
||||
const subject = formData.get('subject') as string
|
||||
const message = formData.get('message') as string
|
||||
const type = formData.get('type') as string
|
||||
const workflowId = formData.get('workflowId') as string | null
|
||||
const workspaceId = formData.get('workspaceId') as string
|
||||
const userAgent = formData.get('userAgent') as string | null
|
||||
|
||||
logger.info(`[${requestId}] Processing help request`, {
|
||||
type,
|
||||
email: `${email.substring(0, 3)}***`, // Log partial email for privacy
|
||||
})
|
||||
|
||||
// Validate the form data
|
||||
const validationResult = helpFormSchema.safeParse({
|
||||
subject,
|
||||
message,
|
||||
@@ -60,7 +59,6 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Extract images
|
||||
const images: { filename: string; content: Buffer; contentType: string }[] = []
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
@@ -81,10 +79,14 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.debug(`[${requestId}] Help request includes ${images.length} images`)
|
||||
|
||||
// Prepare email content
|
||||
const userId = session.user.id
|
||||
let emailText = `
|
||||
Type: ${type}
|
||||
From: ${email}
|
||||
User ID: ${userId}
|
||||
Workspace ID: ${workspaceId ?? 'N/A'}
|
||||
Workflow ID: ${workflowId ?? 'N/A'}
|
||||
Browser: ${userAgent ?? 'N/A'}
|
||||
|
||||
${message}
|
||||
`
|
||||
@@ -115,7 +117,6 @@ ${message}
|
||||
|
||||
logger.info(`[${requestId}] Help request email sent successfully`)
|
||||
|
||||
// Send confirmation email to the user
|
||||
try {
|
||||
const confirmationHtml = await renderHelpConfirmationEmail(
|
||||
type as 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
|
||||
166
apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts
Normal file
166
apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('PermissionGroupBulkMembers')
|
||||
|
||||
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, groupId))
|
||||
.limit(1)
|
||||
|
||||
if (!group) return null
|
||||
|
||||
const [membership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) return null
|
||||
|
||||
return { group, role: membership.role }
|
||||
}
|
||||
|
||||
const bulkAddSchema = z.object({
|
||||
userIds: z.array(z.string()).optional(),
|
||||
addAllOrgMembers: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { userIds, addAllOrgMembers } = bulkAddSchema.parse(body)
|
||||
|
||||
let targetUserIds: string[] = []
|
||||
|
||||
if (addAllOrgMembers) {
|
||||
const orgMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, result.group.organizationId))
|
||||
|
||||
targetUserIds = orgMembers.map((m) => m.userId)
|
||||
} else if (userIds && userIds.length > 0) {
|
||||
const validMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, result.group.organizationId),
|
||||
inArray(member.userId, userIds)
|
||||
)
|
||||
)
|
||||
|
||||
targetUserIds = validMembers.map((m) => m.userId)
|
||||
}
|
||||
|
||||
if (targetUserIds.length === 0) {
|
||||
return NextResponse.json({ added: 0, moved: 0 })
|
||||
}
|
||||
|
||||
const existingMemberships = await db
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
userId: permissionGroupMember.userId,
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.where(inArray(permissionGroupMember.userId, targetUserIds))
|
||||
|
||||
const alreadyInThisGroup = new Set(
|
||||
existingMemberships.filter((m) => m.permissionGroupId === id).map((m) => m.userId)
|
||||
)
|
||||
const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid))
|
||||
|
||||
if (usersToAdd.length === 0) {
|
||||
return NextResponse.json({ added: 0, moved: 0 })
|
||||
}
|
||||
|
||||
const membershipsToDelete = existingMemberships.filter(
|
||||
(m) => m.permissionGroupId !== id && usersToAdd.includes(m.userId)
|
||||
)
|
||||
const movedCount = membershipsToDelete.length
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (membershipsToDelete.length > 0) {
|
||||
await tx.delete(permissionGroupMember).where(
|
||||
inArray(
|
||||
permissionGroupMember.id,
|
||||
membershipsToDelete.map((m) => m.id)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const newMembers = usersToAdd.map((userId) => ({
|
||||
id: crypto.randomUUID(),
|
||||
permissionGroupId: id,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
}))
|
||||
|
||||
await tx.insert(permissionGroupMember).values(newMembers)
|
||||
})
|
||||
|
||||
logger.info('Bulk added members to permission group', {
|
||||
permissionGroupId: id,
|
||||
addedCount: usersToAdd.length,
|
||||
movedCount,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ added: usersToAdd.length, moved: movedCount })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('permission_group_member_user_id_unique')
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'One or more users are already in a permission group' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
logger.error('Error bulk adding members to permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to add members' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
229
apps/sim/app/api/permission-groups/[id]/members/route.ts
Normal file
229
apps/sim/app/api/permission-groups/[id]/members/route.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('PermissionGroupMembers')
|
||||
|
||||
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, groupId))
|
||||
.limit(1)
|
||||
|
||||
if (!group) return null
|
||||
|
||||
const [membership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) return null
|
||||
|
||||
return { group, role: membership.role }
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const members = await db
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
userId: permissionGroupMember.userId,
|
||||
assignedAt: permissionGroupMember.assignedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.leftJoin(user, eq(permissionGroupMember.userId, user.id))
|
||||
.where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
|
||||
return NextResponse.json({ members })
|
||||
}
|
||||
|
||||
const addMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { userId } = addMemberSchema.parse(body)
|
||||
|
||||
const [orgMember] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!orgMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is not a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [existingMembership] = await db
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existingMembership?.permissionGroupId === id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already in this permission group' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const newMember = await db.transaction(async (tx) => {
|
||||
if (existingMembership) {
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.id, existingMembership.id))
|
||||
}
|
||||
|
||||
const memberData = {
|
||||
id: crypto.randomUUID(),
|
||||
permissionGroupId: id,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
}
|
||||
|
||||
await tx.insert(permissionGroupMember).values(memberData)
|
||||
return memberData
|
||||
})
|
||||
|
||||
logger.info('Added member to permission group', {
|
||||
permissionGroupId: id,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ member: newMember }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('permission_group_member_user_id_unique')
|
||||
) {
|
||||
return NextResponse.json({ error: 'User is already in a permission group' }, { status: 409 })
|
||||
}
|
||||
logger.error('Error adding member to permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to add member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const memberId = searchParams.get('memberId')
|
||||
|
||||
if (!memberId) {
|
||||
return NextResponse.json({ error: 'memberId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [memberToRemove] = await db
|
||||
.select()
|
||||
.from(permissionGroupMember)
|
||||
.where(
|
||||
and(eq(permissionGroupMember.id, memberId), eq(permissionGroupMember.permissionGroupId, id))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!memberToRemove) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId))
|
||||
|
||||
logger.info('Removed member from permission group', {
|
||||
permissionGroupId: id,
|
||||
memberId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error removing member from permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
212
apps/sim/app/api/permission-groups/[id]/route.ts
Normal file
212
apps/sim/app/api/permission-groups/[id]/route.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import {
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
|
||||
const logger = createLogger('PermissionGroup')
|
||||
|
||||
const configSchema = z.object({
|
||||
allowedIntegrations: z.array(z.string()).nullable().optional(),
|
||||
allowedModelProviders: z.array(z.string()).nullable().optional(),
|
||||
hideTraceSpans: z.boolean().optional(),
|
||||
hideKnowledgeBaseTab: z.boolean().optional(),
|
||||
hideCopilot: z.boolean().optional(),
|
||||
hideApiKeysTab: z.boolean().optional(),
|
||||
hideEnvironmentTab: z.boolean().optional(),
|
||||
hideFilesTab: z.boolean().optional(),
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
config: configSchema.optional(),
|
||||
})
|
||||
|
||||
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, groupId))
|
||||
.limit(1)
|
||||
|
||||
if (!group) return null
|
||||
|
||||
const [membership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) return null
|
||||
|
||||
return { group, role: membership.role }
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroup: {
|
||||
...result.group,
|
||||
config: parsePermissionGroupConfig(result.group.config),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const updates = updateSchema.parse(body)
|
||||
|
||||
if (updates.name) {
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, result.group.organizationId),
|
||||
eq(permissionGroup.name, updates.name)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0 && existingGroup[0].id !== id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfig = parsePermissionGroupConfig(result.group.config)
|
||||
const newConfig: PermissionGroupConfig = updates.config
|
||||
? { ...currentConfig, ...updates.config }
|
||||
: currentConfig
|
||||
|
||||
await db
|
||||
.update(permissionGroup)
|
||||
.set({
|
||||
...(updates.name !== undefined && { name: updates.name }),
|
||||
...(updates.description !== undefined && { description: updates.description }),
|
||||
config: newConfig,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(permissionGroup.id, id))
|
||||
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, id))
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroup: {
|
||||
...updated,
|
||||
config: parsePermissionGroupConfig(updated.config),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error updating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
await db.delete(permissionGroup).where(eq(permissionGroup.id, id))
|
||||
|
||||
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
185
apps/sim/app/api/permission-groups/route.ts
Normal file
185
apps/sim/app/api/permission-groups/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, desc, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
|
||||
const logger = createLogger('PermissionGroups')
|
||||
|
||||
const configSchema = z.object({
|
||||
allowedIntegrations: z.array(z.string()).nullable().optional(),
|
||||
allowedModelProviders: z.array(z.string()).nullable().optional(),
|
||||
hideTraceSpans: z.boolean().optional(),
|
||||
hideKnowledgeBaseTab: z.boolean().optional(),
|
||||
hideCopilot: z.boolean().optional(),
|
||||
hideApiKeysTab: z.boolean().optional(),
|
||||
hideEnvironmentTab: z.boolean().optional(),
|
||||
hideFilesTab: z.boolean().optional(),
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
organizationId: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
config: configSchema.optional(),
|
||||
})
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const membership = await db
|
||||
.select({ id: member.id, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (membership.length === 0) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const groups = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
creatorName: user.name,
|
||||
creatorEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
.orderBy(desc(permissionGroup.createdAt))
|
||||
|
||||
const groupsWithCounts = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const [memberCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, group.id))
|
||||
|
||||
return {
|
||||
...group,
|
||||
config: parsePermissionGroupConfig(group.config),
|
||||
memberCount: memberCount?.count ?? 0,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ permissionGroups: groupsWithCounts })
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { organizationId, name, description, config } = createSchema.parse(body)
|
||||
|
||||
const membership = await db
|
||||
.select({ id: member.id, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
const role = membership[0]?.role
|
||||
if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const orgExists = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (orgExists.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const groupConfig: PermissionGroupConfig = {
|
||||
...DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
...config,
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const newGroup = {
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
name,
|
||||
description: description || null,
|
||||
config: groupConfig,
|
||||
createdBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(permissionGroup).values(newGroup)
|
||||
|
||||
logger.info('Created permission group', {
|
||||
permissionGroupId: newGroup.id,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error creating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
72
apps/sim/app/api/permission-groups/user/route.ts
Normal file
72
apps/sim/app/api/permission-groups/user/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import { parsePermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Short-circuit: if org is not on enterprise plan, ignore permission configs
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!isEnterprise) {
|
||||
return NextResponse.json({
|
||||
permissionGroupId: null,
|
||||
groupName: null,
|
||||
config: null,
|
||||
})
|
||||
}
|
||||
|
||||
const [groupMembership] = await db
|
||||
.select({
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
config: permissionGroup.config,
|
||||
groupName: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, session.user.id),
|
||||
eq(permissionGroup.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!groupMembership) {
|
||||
return NextResponse.json({
|
||||
permissionGroupId: null,
|
||||
groupName: null,
|
||||
config: null,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroupId: groupMembership.permissionGroupId,
|
||||
groupName: groupMembership.groupName,
|
||||
config: parsePermissionGroupConfig(groupMembership.config),
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
extractRequiredCredentials,
|
||||
sanitizeCredentials,
|
||||
} from '@/lib/workflows/credentials/credential-extractor'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
@@ -189,12 +190,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
const currentState: Partial<WorkflowState> = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: workflowRecord?.variables || undefined,
|
||||
variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { regenerateWorkflowStateIds } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
type RegenerateStateInput,
|
||||
regenerateWorkflowStateIds,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
|
||||
const logger = createLogger('TemplateUseAPI')
|
||||
|
||||
@@ -104,9 +107,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
// Step 2: Regenerate IDs when creating a copy (not when connecting/editing template)
|
||||
// When connecting to template (edit mode), keep original IDs
|
||||
// When using template (copy mode), regenerate all IDs to avoid conflicts
|
||||
const templateState = templateData.state as RegenerateStateInput
|
||||
const workflowState = connectToTemplate
|
||||
? templateData.state
|
||||
: regenerateWorkflowStateIds(templateData.state)
|
||||
? templateState
|
||||
: regenerateWorkflowStateIds(templateState)
|
||||
|
||||
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
|
||||
// Ensure variables in state are remapped for the new workflow as well
|
||||
|
||||
169
apps/sim/app/api/v1/admin/access-control/route.ts
Normal file
169
apps/sim/app/api/v1/admin/access-control/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Admin Access Control (Permission Groups) API
|
||||
*
|
||||
* GET /api/v1/admin/access-control
|
||||
* List all permission groups with optional filtering.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId?: string - Filter by organization ID
|
||||
*
|
||||
* Response: { data: AdminPermissionGroup[], pagination: PaginationMeta }
|
||||
*
|
||||
* DELETE /api/v1/admin/access-control
|
||||
* Delete permission groups for an organization.
|
||||
* Used when an enterprise plan churns to clean up access control data.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId: string - Delete all permission groups for this organization
|
||||
*
|
||||
* Response: { success: true, deletedCount: number, membersRemoved: number }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, 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('AdminAccessControlAPI')
|
||||
|
||||
export interface AdminPermissionGroup {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationName: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
memberCount: number
|
||||
createdAt: string
|
||||
createdByUserId: string
|
||||
createdByEmail: string | null
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
|
||||
try {
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
organizationName: organization.name,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
createdByUserId: permissionGroup.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(organization, eq(permissionGroup.organizationId, organization.id))
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
|
||||
let groups
|
||||
if (organizationId) {
|
||||
groups = await baseQuery.where(eq(permissionGroup.organizationId, organizationId))
|
||||
} else {
|
||||
groups = await baseQuery
|
||||
}
|
||||
|
||||
const groupsWithCounts = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const [memberCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, group.id))
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
organizationId: group.organizationId,
|
||||
organizationName: group.organizationName,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
memberCount: memberCount?.count ?? 0,
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
createdByUserId: group.createdByUserId,
|
||||
createdByEmail: group.createdByEmail,
|
||||
} as AdminPermissionGroup
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('Admin API: Listed permission groups', {
|
||||
organizationId,
|
||||
count: groupsWithCounts.length,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
data: groupsWithCounts,
|
||||
pagination: {
|
||||
total: groupsWithCounts.length,
|
||||
limit: groupsWithCounts.length,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list permission groups', { error, organizationId })
|
||||
return internalErrorResponse('Failed to list permission groups')
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
|
||||
|
||||
if (!organizationId) {
|
||||
return badRequestResponse('organizationId is required')
|
||||
}
|
||||
|
||||
try {
|
||||
const existingGroups = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
|
||||
if (existingGroups.length === 0) {
|
||||
logger.info('Admin API: No permission groups to delete', { organizationId })
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
membersRemoved: 0,
|
||||
message: 'No permission groups found for the given organization',
|
||||
})
|
||||
}
|
||||
|
||||
const groupIds = existingGroups.map((g) => g.id)
|
||||
|
||||
const [memberCountResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(permissionGroupMember)
|
||||
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
|
||||
|
||||
const membersToRemove = Number(memberCountResult?.count ?? 0)
|
||||
|
||||
// Members are deleted via cascade when permission groups are deleted
|
||||
await db.delete(permissionGroup).where(eq(permissionGroup.organizationId, organizationId))
|
||||
|
||||
logger.info('Admin API: Deleted permission groups', {
|
||||
organizationId,
|
||||
deletedCount: existingGroups.length,
|
||||
membersRemoved: membersToRemove,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: existingGroups.length,
|
||||
membersRemoved: membersToRemove,
|
||||
reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete permission groups', { error, organizationId })
|
||||
return internalErrorResponse('Failed to delete permission groups')
|
||||
}
|
||||
})
|
||||
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')
|
||||
}
|
||||
})
|
||||
@@ -36,6 +36,7 @@
|
||||
*
|
||||
* Organizations:
|
||||
* GET /api/v1/admin/organizations - List all organizations
|
||||
* POST /api/v1/admin/organizations - Create organization (requires ownerId)
|
||||
* GET /api/v1/admin/organizations/:id - Get organization details
|
||||
* PATCH /api/v1/admin/organizations/:id - Update organization
|
||||
* GET /api/v1/admin/organizations/:id/members - List organization members
|
||||
@@ -51,6 +52,14 @@
|
||||
* 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
|
||||
*
|
||||
* Access Control (Permission Groups):
|
||||
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
||||
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
||||
*/
|
||||
|
||||
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -39,6 +40,42 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
if (!isBillingEnabled) {
|
||||
const [[orgData], [memberCount]] = await Promise.all([
|
||||
db.select().from(organization).where(eq(organization.id, organizationId)).limit(1),
|
||||
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
|
||||
])
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const data: AdminOrganizationBillingSummary = {
|
||||
organizationId: orgData.id,
|
||||
organizationName: orgData.name,
|
||||
subscriptionPlan: 'none',
|
||||
subscriptionStatus: 'none',
|
||||
totalSeats: Number.MAX_SAFE_INTEGER,
|
||||
usedSeats: memberCount?.count || 0,
|
||||
availableSeats: Number.MAX_SAFE_INTEGER,
|
||||
totalCurrentUsage: 0,
|
||||
totalUsageLimit: Number.MAX_SAFE_INTEGER,
|
||||
minimumBillingAmount: 0,
|
||||
averageUsagePerMember: 0,
|
||||
usagePercentage: 0,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
membersOverLimit: 0,
|
||||
membersNearLimit: 0,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)`
|
||||
)
|
||||
|
||||
return singleResponse(data)
|
||||
}
|
||||
|
||||
const billingData = await getOrganizationBillingData(organizationId)
|
||||
|
||||
if (!billingData) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -182,7 +183,7 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
|
||||
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId, memberId } = await context.params
|
||||
const url = new URL(request.url)
|
||||
const skipBillingLogic = url.searchParams.get('skipBillingLogic') === 'true'
|
||||
const skipBillingLogic = !isBillingEnabled || url.searchParams.get('skipBillingLogic') === 'true'
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
|
||||
@@ -34,6 +34,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -221,14 +222,14 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
||||
userId: body.userId,
|
||||
organizationId,
|
||||
role: body.role,
|
||||
skipBillingLogic: !isBillingEnabled,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return badRequestResponse(result.error || 'Failed to add member')
|
||||
}
|
||||
|
||||
// Sync Pro subscription cancellation with Stripe (same as invitation flow)
|
||||
if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
|
||||
if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(
|
||||
|
||||
@@ -8,14 +8,32 @@
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminOrganization>
|
||||
*
|
||||
* POST /api/v1/admin/organizations
|
||||
*
|
||||
* Create a new organization.
|
||||
*
|
||||
* Body:
|
||||
* - name: string - Organization name (required)
|
||||
* - slug: string - Organization slug (optional, auto-generated from name if not provided)
|
||||
* - ownerId: string - User ID of the organization owner (required)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganization & { memberId: string }>
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { member, organization, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count } from 'drizzle-orm'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminOrganization,
|
||||
createPaginationMeta,
|
||||
@@ -47,3 +65,90 @@ export const GET = withAdminAuth(async (request) => {
|
||||
return internalErrorResponse('Failed to list organizations')
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = withAdminAuth(async (request) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||
return badRequestResponse('name is required')
|
||||
}
|
||||
|
||||
if (!body.ownerId || typeof body.ownerId !== 'string') {
|
||||
return badRequestResponse('ownerId is required')
|
||||
}
|
||||
|
||||
const [ownerData] = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, body.ownerId))
|
||||
.limit(1)
|
||||
|
||||
if (!ownerData) {
|
||||
return notFoundResponse('Owner user')
|
||||
}
|
||||
|
||||
const [existingMembership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, body.ownerId))
|
||||
.limit(1)
|
||||
|
||||
if (existingMembership) {
|
||||
return badRequestResponse(
|
||||
'User is already a member of another organization. Users can only belong to one organization at a time.'
|
||||
)
|
||||
}
|
||||
|
||||
const name = body.name.trim()
|
||||
const slug =
|
||||
body.slug?.trim() ||
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
const organizationId = randomUUID()
|
||||
const memberId = randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(organization).values({
|
||||
id: organizationId,
|
||||
name,
|
||||
slug,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await tx.insert(member).values({
|
||||
id: memberId,
|
||||
userId: body.ownerId,
|
||||
organizationId,
|
||||
role: 'owner',
|
||||
createdAt: now,
|
||||
})
|
||||
})
|
||||
|
||||
const [createdOrg] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
logger.info(`Admin API: Created organization ${organizationId}`, {
|
||||
name,
|
||||
slug,
|
||||
ownerId: body.ownerId,
|
||||
memberId,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
...toAdminOrganization(createdOrg),
|
||||
memberId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to create organization', { error })
|
||||
return internalErrorResponse('Failed to create organization')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -243,7 +243,7 @@ export interface WorkflowExportState {
|
||||
color?: string
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: WorkflowVariable[]
|
||||
variables?: Record<string, WorkflowVariable>
|
||||
}
|
||||
|
||||
export interface WorkflowExportPayload {
|
||||
@@ -317,36 +317,44 @@ export interface WorkspaceImportResponse {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse workflow variables from database JSON format to array format.
|
||||
* Handles both array and Record<string, Variable> formats.
|
||||
* Parse workflow variables from database JSON format to Record format.
|
||||
* Handles both legacy Array and current Record<string, Variable> formats.
|
||||
*/
|
||||
export function parseWorkflowVariables(
|
||||
dbVariables: DbWorkflow['variables']
|
||||
): WorkflowVariable[] | undefined {
|
||||
): Record<string, WorkflowVariable> | undefined {
|
||||
if (!dbVariables) return undefined
|
||||
|
||||
try {
|
||||
const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
|
||||
|
||||
// Handle legacy Array format by converting to Record
|
||||
if (Array.isArray(varsObj)) {
|
||||
return varsObj.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}))
|
||||
const result: Record<string, WorkflowVariable> = {}
|
||||
for (const v of varsObj) {
|
||||
result[v.id] = {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Already Record format - normalize and return
|
||||
if (typeof varsObj === 'object' && varsObj !== null) {
|
||||
return Object.values(varsObj).map((v: unknown) => {
|
||||
const result: Record<string, WorkflowVariable> = {}
|
||||
for (const [key, v] of Object.entries(varsObj)) {
|
||||
const variable = v as { id: string; name: string; type: VariableType; value: unknown }
|
||||
return {
|
||||
result[key] = {
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
value: variable.value,
|
||||
}
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
// pass
|
||||
|
||||
@@ -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}.`,
|
||||
|
||||
@@ -74,8 +74,6 @@ export async function POST(
|
||||
loops: deployedState.loops || {},
|
||||
parallels: deployedState.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: true,
|
||||
deployedAt: new Date(),
|
||||
deploymentStatuses: deployedState.deploymentStatuses || {},
|
||||
})
|
||||
|
||||
@@ -88,7 +86,6 @@ export async function POST(
|
||||
.set({ lastSynced: new Date(), updatedAt: new Date() })
|
||||
.where(eq(workflow.id, id))
|
||||
|
||||
// Sync MCP tools with the reverted version's parameter schema
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: id,
|
||||
requestId,
|
||||
|
||||
@@ -207,9 +207,15 @@ describe('Workflow Variables API Route', () => {
|
||||
update: { results: [{}] },
|
||||
})
|
||||
|
||||
const variables = [
|
||||
{ id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' },
|
||||
]
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
method: 'POST',
|
||||
@@ -242,9 +248,15 @@ describe('Workflow Variables API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const variables = [
|
||||
{ id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' },
|
||||
]
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
method: 'POST',
|
||||
@@ -277,7 +289,6 @@ describe('Workflow Variables API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Invalid data - missing required fields
|
||||
const invalidData = { variables: [{ name: 'test' }] }
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
|
||||
@@ -11,16 +11,22 @@ import type { Variable } from '@/stores/panel/variables/types'
|
||||
|
||||
const logger = createLogger('WorkflowVariablesAPI')
|
||||
|
||||
const VariableSchema = z.object({
|
||||
id: z.string(),
|
||||
workflowId: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
|
||||
value: z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.record(z.unknown()),
|
||||
z.array(z.unknown()),
|
||||
]),
|
||||
})
|
||||
|
||||
const VariablesSchema = z.object({
|
||||
variables: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
workflowId: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
|
||||
value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]),
|
||||
})
|
||||
),
|
||||
variables: z.record(z.string(), VariableSchema),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -60,21 +66,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
const { variables } = VariablesSchema.parse(body)
|
||||
|
||||
// Format variables for storage
|
||||
const variablesRecord: Record<string, Variable> = {}
|
||||
variables.forEach((variable) => {
|
||||
variablesRecord[variable.id] = variable
|
||||
})
|
||||
|
||||
// Replace variables completely with the incoming ones
|
||||
// Variables are already in Record format - use directly
|
||||
// The frontend is the source of truth for what variables should exist
|
||||
const updatedVariables = variablesRecord
|
||||
|
||||
// Update workflow with variables
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
variables: updatedVariables,
|
||||
variables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
@@ -148,8 +145,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
headers,
|
||||
}
|
||||
)
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workflow variables fetch error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -332,7 +332,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return (
|
||||
<WorkflowPreview
|
||||
workflowState={template.state}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
|
||||
@@ -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'
|
||||
@@ -106,8 +106,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
isDeployed: input.isDeployed,
|
||||
deployedAt: input.deployedAt,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
@@ -204,7 +202,6 @@ function TemplateCardInner({
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
|
||||
@@ -95,7 +95,12 @@ export function ChunkContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -100,7 +100,12 @@ export function DocumentContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -99,7 +99,12 @@ export function KnowledgeBaseContextMenu({
|
||||
disableDelete = false,
|
||||
}: KnowledgeBaseContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -43,7 +43,12 @@ export function KnowledgeListContextMenu({
|
||||
disableAdd = false,
|
||||
}: KnowledgeListContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
export { Knowledge as default } from './knowledge'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { Knowledge } from './knowledge'
|
||||
|
||||
interface KnowledgePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function KnowledgePage({ params }: KnowledgePageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Check permission group restrictions
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideKnowledgeBaseTab) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
return <Knowledge />
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
export { SnapshotContextMenu } from './snapshot-context-menu'
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface SnapshotContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
onCopy: () => void
|
||||
onSearch?: () => void
|
||||
wrapText?: boolean
|
||||
onToggleWrap?: () => void
|
||||
/** When true, only shows Copy option (for subblock values) */
|
||||
copyOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for execution snapshot sidebar.
|
||||
* Provides copy, search, and display options.
|
||||
* Uses createPortal to render outside any transformed containers (like modals).
|
||||
*/
|
||||
export function SnapshotContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onCopy,
|
||||
onSearch,
|
||||
wrapText,
|
||||
onToggleWrap,
|
||||
copyOnly = false,
|
||||
}: SnapshotContextMenuProps) {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopy()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</PopoverItem>
|
||||
|
||||
{!copyOnly && onSearch && (
|
||||
<>
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onSearch()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</PopoverItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!copyOnly && onToggleWrap && (
|
||||
<>
|
||||
<PopoverDivider />
|
||||
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
|
||||
Wrap Text
|
||||
</PopoverItem>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
BlockDetailsSidebar,
|
||||
getLeftmostBlockId,
|
||||
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 autoSelectedForExecutionRef = useRef<string | null>(null)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsMenuOpen(false)
|
||||
setContextMenuBlockId(null)
|
||||
}, [])
|
||||
|
||||
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuBlockId(null)
|
||||
setMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleNodeContextMenu = useCallback(
|
||||
(blockId: string, mousePosition: { x: number; y: number }) => {
|
||||
setContextMenuBlockId(blockId)
|
||||
setMenuPosition(mousePosition)
|
||||
setIsMenuOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCopyExecutionId = useCallback(() => {
|
||||
navigator.clipboard.writeText(executionId)
|
||||
closeMenu()
|
||||
}, [executionId, closeMenu])
|
||||
|
||||
const handleOpenDetails = useCallback(() => {
|
||||
if (contextMenuBlockId) {
|
||||
setPinnedBlockId(contextMenuBlockId)
|
||||
}
|
||||
closeMenu()
|
||||
}, [contextMenuBlockId, closeMenu])
|
||||
|
||||
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])
|
||||
|
||||
const workflowState = data?.workflowState as WorkflowState | undefined
|
||||
|
||||
// Auto-select the leftmost block once when data loads for a new executionId
|
||||
useEffect(() => {
|
||||
if (
|
||||
workflowState &&
|
||||
!isMigratedWorkflowState(workflowState) &&
|
||||
autoSelectedForExecutionRef.current !== executionId
|
||||
) {
|
||||
autoSelectedForExecutionRef.current = executionId
|
||||
const leftmostId = getLeftmostBlockId(workflowState)
|
||||
setPinnedBlockId(leftmostId)
|
||||
}
|
||||
}, [executionId, workflowState])
|
||||
|
||||
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',
|
||||
!isModal && 'rounded-[4px] border border-[var(--border)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={(blockId) => {
|
||||
setPinnedBlockId(blockId)
|
||||
}}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneClick={() => setPinnedBlockId(null)}
|
||||
cursorStyle='pointer'
|
||||
executedBlocks={blockExecutions}
|
||||
selectedBlockId={pinnedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
block={workflowState.blocks[pinnedBlockId]}
|
||||
executionData={blockExecutions[pinnedBlockId]}
|
||||
allBlockExecutions={blockExecutions}
|
||||
workflowBlocks={workflowState.blocks}
|
||||
workflowVariables={workflowState.variables}
|
||||
loops={workflowState.loops}
|
||||
parallels={workflowState.parallels}
|
||||
isExecutionMode
|
||||
onClose={() => setPinnedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canvasContextMenu =
|
||||
typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<Popover
|
||||
open={isMenuOpen}
|
||||
onOpenChange={closeMenu}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${menuPosition.x}px`,
|
||||
top: `${menuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{contextMenuBlockId && (
|
||||
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
|
||||
)}
|
||||
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
|
||||
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 overflow-hidden'>{renderContent()}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -1,13 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDown, Code } from '@/components/emcn'
|
||||
import { ArrowDown, ArrowUp, X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Button,
|
||||
ChevronDown,
|
||||
Code,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
|
||||
interface TraceSpansProps {
|
||||
@@ -370,7 +384,7 @@ function SpanContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders input/output section with collapsible content
|
||||
* Renders input/output section with collapsible content, context menu, and search
|
||||
*/
|
||||
function InputOutputSection({
|
||||
label,
|
||||
@@ -391,14 +405,63 @@ function InputOutputSection({
|
||||
}) {
|
||||
const sectionKey = `${spanId}-${sectionType}`
|
||||
const isExpanded = expandedSections.has(sectionKey)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Context menu state
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
// Code viewer features
|
||||
const {
|
||||
wrapText,
|
||||
toggleWrapText,
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
matchCount,
|
||||
currentMatchIndex,
|
||||
activateSearch,
|
||||
closeSearch,
|
||||
goToNextMatch,
|
||||
goToPreviousMatch,
|
||||
handleMatchCountChange,
|
||||
searchInputRef,
|
||||
} = useCodeViewerFeatures({ contentRef })
|
||||
|
||||
const jsonString = useMemo(() => {
|
||||
if (!data) return ''
|
||||
return JSON.stringify(data, null, 2)
|
||||
}, [data])
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsContextMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setIsContextMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(jsonString)
|
||||
closeContextMenu()
|
||||
}, [jsonString, closeContextMenu])
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
activateSearch()
|
||||
closeContextMenu()
|
||||
}, [activateSearch, closeContextMenu])
|
||||
|
||||
const handleToggleWrap = useCallback(() => {
|
||||
toggleWrapText()
|
||||
closeContextMenu()
|
||||
}, [toggleWrapText, closeContextMenu])
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
||||
<div className='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
||||
<div
|
||||
className='group flex cursor-pointer items-center justify-between'
|
||||
onClick={() => onToggle(sectionKey)}
|
||||
@@ -433,12 +496,101 @@ function InputOutputSection({
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<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 ref={contentRef} onContextMenu={handleContextMenu}>
|
||||
<Code.Viewer
|
||||
code={jsonString}
|
||||
language='json'
|
||||
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
|
||||
wrapText={wrapText}
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Overlay */}
|
||||
{isSearchActive && (
|
||||
<div
|
||||
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder='Search...'
|
||||
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-[45px] text-center text-[11px]',
|
||||
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={goToPreviousMatch}
|
||||
disabled={matchCount === 0}
|
||||
aria-label='Previous match'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={goToNextMatch}
|
||||
disabled={matchCount === 0}
|
||||
aria-label='Next match'
|
||||
>
|
||||
<ArrowDown className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={closeSearch}
|
||||
aria-label='Close search'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
|
||||
{typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<Popover
|
||||
open={isContextMenuOpen}
|
||||
onOpenChange={closeContextMenu}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenuPosition.x}px`,
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
|
||||
<PopoverDivider />
|
||||
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
|
||||
<PopoverItem showCheck={wrapText} onClick={handleToggleWrap}>
|
||||
Wrap Text
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
@@ -13,6 +17,7 @@ import {
|
||||
StatusBadge,
|
||||
TriggerBadge,
|
||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { useLogDetailsUIStore } from '@/stores/logs/store'
|
||||
@@ -49,10 +54,11 @@ 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()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
@@ -260,13 +266,13 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
|
||||
{/* Workflow State */}
|
||||
{isWorkflowExecutionLog && log.executionId && (
|
||||
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
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)]'>
|
||||
@@ -278,12 +284,14 @@ export const LogDetails = memo(function LogDetails({
|
||||
)}
|
||||
|
||||
{/* Workflow Execution - Trace Spans */}
|
||||
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
/>
|
||||
)}
|
||||
{isWorkflowExecutionLog &&
|
||||
log.executionData?.traceSpans &&
|
||||
!permissionConfig.hideTraceSpans && (
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{log.files && log.files.length > 0 && (
|
||||
@@ -363,12 +371,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,
|
||||
@@ -45,7 +47,12 @@ export function LogRowContextMenu({
|
||||
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -78,6 +85,15 @@ export function LogRowContextMenu({
|
||||
>
|
||||
Open Workflow
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
disabled={!hasExecutionId}
|
||||
onClick={() => {
|
||||
onOpenPreview()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open Snapshot
|
||||
</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
|
||||
@@ -112,8 +109,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
isDeployed: input.isDeployed,
|
||||
deployedAt: input.deployedAt,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
@@ -213,7 +208,6 @@ function TemplateCardInner({
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
@@ -32,6 +33,12 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Check permission group restrictions
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideTemplates) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
// Determine effective super user (DB flag AND UI mode enabled)
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useDebounce } from '@/hooks/use-debounce'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Template data structure with support for both new and legacy fields
|
||||
* Template data structure
|
||||
*/
|
||||
export interface Template {
|
||||
/** Unique identifier for the template */
|
||||
@@ -59,16 +59,6 @@ export interface Template {
|
||||
isStarred: boolean
|
||||
/** Whether the current user is a super user */
|
||||
isSuperUser?: boolean
|
||||
/** @deprecated Legacy field - use creator.referenceId instead */
|
||||
userId?: string
|
||||
/** @deprecated Legacy field - use details.tagline instead */
|
||||
description?: string | null
|
||||
/** @deprecated Legacy field - use creator.name instead */
|
||||
author?: string
|
||||
/** @deprecated Legacy field - use creator.referenceType instead */
|
||||
authorType?: 'user' | 'organization'
|
||||
/** @deprecated Legacy field - use creator.referenceId when referenceType is 'organization' */
|
||||
organizationId?: string | null
|
||||
/** Display color for the template card */
|
||||
color?: string
|
||||
/** Display icon for the template card */
|
||||
@@ -107,7 +97,6 @@ export default function Templates({
|
||||
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
@@ -115,7 +104,7 @@ export default function Templates({
|
||||
return templates.filter((template) => {
|
||||
const tabMatch =
|
||||
activeTab === 'your'
|
||||
? template.userId === currentUserId || template.isStarred
|
||||
? template.creator?.referenceId === currentUserId || template.isStarred
|
||||
: activeTab === 'gallery'
|
||||
? template.status === 'approved'
|
||||
: template.status === 'pending'
|
||||
@@ -124,13 +113,7 @@ export default function Templates({
|
||||
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.details?.tagline,
|
||||
template.author,
|
||||
template.creator?.name,
|
||||
]
|
||||
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
@@ -141,7 +124,6 @@ export default function Templates({
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
@@ -235,25 +217,20 @@ export default function Templates({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((template) => {
|
||||
const author = template.author || template.creator?.name || 'Unknown'
|
||||
const authorImageUrl = template.creator?.profileImageUrl || null
|
||||
|
||||
return (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
author={author}
|
||||
authorImageUrl={authorImageUrl}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
isVerified={template.creator?.verified || false}
|
||||
/>
|
||||
)
|
||||
})
|
||||
filteredTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
authorImageUrl={template.creator?.profileImageUrl || null}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
isVerified={template.creator?.verified || false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
useFloatBoundarySync,
|
||||
useFloatDrag,
|
||||
useFloatResize,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float'
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { getChatPosition, useChatStore } from '@/stores/chat/store'
|
||||
@@ -726,7 +726,9 @@ export function Chat() {
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
if (!isStreaming && !isExecuting) {
|
||||
handleSendMessage()
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (promptHistory.length > 0) {
|
||||
@@ -749,7 +751,7 @@ export function Chat() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSendMessage, promptHistory, historyIndex]
|
||||
[handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1061,7 +1063,7 @@ export function Chat() {
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
|
||||
className='w-full border-0 bg-transparent pr-[56px] pl-[4px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
disabled={!activeWorkflowId || isExecuting}
|
||||
disabled={!activeWorkflowId}
|
||||
/>
|
||||
|
||||
{/* Buttons positioned absolutely on the right */}
|
||||
@@ -1091,7 +1093,8 @@ export function Chat() {
|
||||
disabled={
|
||||
(!chatMessage.trim() && chatFiles.length === 0) ||
|
||||
!activeWorkflowId ||
|
||||
isExecuting
|
||||
isExecuting ||
|
||||
isStreaming
|
||||
}
|
||||
className={cn(
|
||||
'h-[22px] w-[22px] rounded-full p-0 transition-colors',
|
||||
|
||||
@@ -56,7 +56,7 @@ export function BlockContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
@@ -118,7 +118,7 @@ export function BlockContextMenu({
|
||||
{getToggleEnabledLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!allNoteBlocks && (
|
||||
{!allNoteBlocks && !isSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function PaneContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -109,12 +109,7 @@ export const DiffControls = memo(function DiffControls() {
|
||||
loops: rawState.loops || {},
|
||||
parallels: rawState.parallels || {},
|
||||
lastSaved: rawState.lastSaved || Date.now(),
|
||||
isDeployed: rawState.isDeployed || false,
|
||||
deploymentStatuses: rawState.deploymentStatuses || {},
|
||||
// Only include deployedAt if it's a valid date, never include null/undefined
|
||||
...(rawState.deployedAt && rawState.deployedAt instanceof Date
|
||||
? { deployedAt: rawState.deployedAt }
|
||||
: {}),
|
||||
}
|
||||
|
||||
logger.info('Prepared complete workflow state for checkpoint', {
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const LIMIT_INCREMENTS = [0, 50, 100] as const
|
||||
|
||||
function roundUpToNearest50(value: number): number {
|
||||
@@ -15,7 +17,7 @@ function roundUpToNearest50(value: number): number {
|
||||
}
|
||||
|
||||
export function UsageLimitActions() {
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const updateUsageLimitMutation = useUpdateUsageLimit()
|
||||
|
||||
const subscription = subscriptionData?.data
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -92,6 +93,8 @@ interface UseMentionDataProps {
|
||||
export function useMentionData(props: UseMentionDataProps) {
|
||||
const { workflowId, workspaceId } = props
|
||||
|
||||
const { config, isBlockAllowed } = usePermissionConfig()
|
||||
|
||||
const [pastChats, setPastChats] = useState<PastChat[]>([])
|
||||
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
|
||||
|
||||
@@ -101,6 +104,11 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
|
||||
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
|
||||
|
||||
// Reset blocks list when permission config changes
|
||||
useEffect(() => {
|
||||
setBlocksList([])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
|
||||
|
||||
@@ -252,7 +260,13 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
const { getAllBlocks } = await import('@/blocks')
|
||||
const all = getAllBlocks()
|
||||
const regularBlocks = all
|
||||
.filter((b: any) => b.type !== 'starter' && !b.hideFromToolbar && b.category === 'blocks')
|
||||
.filter(
|
||||
(b: any) =>
|
||||
b.type !== 'starter' &&
|
||||
!b.hideFromToolbar &&
|
||||
b.category === 'blocks' &&
|
||||
isBlockAllowed(b.type)
|
||||
)
|
||||
.map((b: any) => ({
|
||||
id: b.type,
|
||||
name: b.name || b.type,
|
||||
@@ -262,7 +276,13 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
|
||||
const toolBlocks = all
|
||||
.filter((b: any) => b.type !== 'starter' && !b.hideFromToolbar && b.category === 'tools')
|
||||
.filter(
|
||||
(b: any) =>
|
||||
b.type !== 'starter' &&
|
||||
!b.hideFromToolbar &&
|
||||
b.category === 'tools' &&
|
||||
isBlockAllowed(b.type)
|
||||
)
|
||||
.map((b: any) => ({
|
||||
id: b.type,
|
||||
name: b.name || b.type,
|
||||
@@ -276,7 +296,7 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
} finally {
|
||||
setIsLoadingBlocks(false)
|
||||
}
|
||||
}, [isLoadingBlocks, blocksList.length])
|
||||
}, [isLoadingBlocks, blocksList.length, isBlockAllowed])
|
||||
|
||||
/**
|
||||
* Ensures templates are loaded
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
@@ -10,10 +11,16 @@ 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,
|
||||
getLeftmostBlockId,
|
||||
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 +56,27 @@ 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 hasAutoSelectedRef = useRef(false)
|
||||
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 +95,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 +110,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,18 +122,30 @@ 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
|
||||
|
||||
// Auto-select the leftmost block once when expanded preview opens
|
||||
useEffect(() => {
|
||||
if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) {
|
||||
hasAutoSelectedRef.current = true
|
||||
const leftmostId = getLeftmostBlockId(workflowToShow)
|
||||
setExpandedSelectedBlockId(leftmostId)
|
||||
}
|
||||
// Reset when modal closes
|
||||
if (!showExpandedPreview) {
|
||||
hasAutoSelectedRef.current = false
|
||||
}
|
||||
}, [showExpandedPreview, workflowToShow])
|
||||
|
||||
if (showLoadingSkeleton) {
|
||||
return (
|
||||
<div className='space-y-[12px]'>
|
||||
@@ -212,22 +201,38 @@ export function GeneralDeploy({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
|
||||
className='relative h-[260px] w-full overflow-hidden rounded-[4px] border border-[var(--border)]'
|
||||
onWheelCapture={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{workflowToShow ? (
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
/>
|
||||
<>
|
||||
<div className='[&_*]:!cursor-default h-full w-full cursor-default'>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={() => setShowExpandedPreview(true)}
|
||||
className='absolute right-[8px] bottom-[8px] z-10 h-[28px] w-[28px] cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>See 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 +309,53 @@ 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}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
onNodeClick={(blockId) => {
|
||||
setExpandedSelectedBlockId(blockId)
|
||||
}}
|
||||
onPaneClick={() => setExpandedSelectedBlockId(null)}
|
||||
selectedBlockId={expandedSelectedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
||||
workflowVariables={workflowToShow.variables}
|
||||
loops={workflowToShow.loops}
|
||||
parallels={workflowToShow.parallels}
|
||||
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,
|
||||
@@ -488,7 +488,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={false}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
@@ -529,7 +528,6 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
|
||||
<WorkflowPreview
|
||||
key={`template-preview-${existingTemplate.id}`}
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
@@ -132,10 +134,27 @@ export function ComboBox({
|
||||
// Determine the active value based on mode (preview vs. controlled vs. store)
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
// Permission-based filtering for model dropdowns
|
||||
const { isProviderAllowed, isLoading: isPermissionLoading } = usePermissionConfig()
|
||||
|
||||
// Evaluate static options if provided as a function
|
||||
const staticOptions = useMemo(() => {
|
||||
return typeof options === 'function' ? options() : options
|
||||
}, [options])
|
||||
const opts = typeof options === 'function' ? options() : options
|
||||
|
||||
if (subBlockId === 'model') {
|
||||
return opts.filter((opt) => {
|
||||
const modelId = typeof opt === 'string' ? opt : opt.id
|
||||
try {
|
||||
const providerId = getProviderFromModel(modelId)
|
||||
return isProviderAllowed(providerId)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return opts
|
||||
}, [options, subBlockId, isProviderAllowed])
|
||||
|
||||
// Normalize fetched options to match ComboBoxOption format
|
||||
const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => {
|
||||
@@ -147,6 +166,18 @@ export function ComboBox({
|
||||
let opts: ComboBoxOption[] =
|
||||
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
|
||||
|
||||
if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
|
||||
opts = opts.filter((opt) => {
|
||||
const modelId = typeof opt === 'string' ? opt : opt.id
|
||||
try {
|
||||
const providerId = getProviderFromModel(modelId)
|
||||
return isProviderAllowed(providerId)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Merge hydrated option if not already present
|
||||
if (hydratedOption) {
|
||||
const alreadyPresent = opts.some((o) =>
|
||||
@@ -158,7 +189,14 @@ export function ComboBox({
|
||||
}
|
||||
|
||||
return opts
|
||||
}, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption])
|
||||
}, [
|
||||
fetchOptions,
|
||||
normalizedFetchedOptions,
|
||||
staticOptions,
|
||||
hydratedOption,
|
||||
subBlockId,
|
||||
isProviderAllowed,
|
||||
])
|
||||
|
||||
// Convert options to Combobox format
|
||||
const comboboxOptions = useMemo((): ComboboxOption[] => {
|
||||
@@ -231,16 +269,34 @@ export function ComboBox({
|
||||
setStoreInitialized(true)
|
||||
}, [])
|
||||
|
||||
// Set default value once store is initialized and value is undefined
|
||||
// Check if current value is valid (exists in allowed options)
|
||||
const isValueValid = useMemo(() => {
|
||||
if (value === null || value === undefined) return false
|
||||
return evaluatedOptions.some((opt) => getOptionValue(opt) === value)
|
||||
}, [value, evaluatedOptions, getOptionValue])
|
||||
|
||||
// Set default value once store is initialized and permissions are loaded
|
||||
// Also reset if current value becomes invalid (e.g., provider was blocked)
|
||||
useEffect(() => {
|
||||
if (
|
||||
storeInitialized &&
|
||||
(value === null || value === undefined) &&
|
||||
defaultOptionValue !== undefined
|
||||
) {
|
||||
if (isPermissionLoading) return
|
||||
if (!storeInitialized) return
|
||||
if (defaultOptionValue === undefined) return
|
||||
|
||||
const needsDefault = value === null || value === undefined
|
||||
const needsReset = subBlockId === 'model' && value && !isValueValid
|
||||
|
||||
if (needsDefault || needsReset) {
|
||||
setStoreValue(defaultOptionValue)
|
||||
}
|
||||
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
|
||||
}, [
|
||||
storeInitialized,
|
||||
value,
|
||||
defaultOptionValue,
|
||||
setStoreValue,
|
||||
isPermissionLoading,
|
||||
subBlockId,
|
||||
isValueValid,
|
||||
])
|
||||
|
||||
// Clear fetched options and hydrated option when dependencies change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { ExternalLink, Users } from 'lucide-react'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
@@ -26,6 +27,7 @@ import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
interface CredentialSelectorProps {
|
||||
blockId: string
|
||||
@@ -54,7 +56,7 @@ export function CredentialSelector({
|
||||
const supportsCredentialSets = subBlock.supportsCredentialSets || false
|
||||
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -19,7 +19,9 @@ export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps)
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
|
||||
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone'))
|
||||
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) as
|
||||
| string
|
||||
| undefined
|
||||
|
||||
const { data: schedule, isLoading } = useScheduleQuery(workflowId, blockId, {
|
||||
enabled: !isPreview,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
type ComboboxOptionGroup,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hooks/queries/mcp'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -673,7 +675,7 @@ function WorkflowInputMapperSyncWrapper({
|
||||
|
||||
if (!workflowId) {
|
||||
return (
|
||||
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 text-sm'>
|
||||
<div className='rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-4 text-center text-[var(--text-muted)] text-sm'>
|
||||
Select a workflow to configure its inputs
|
||||
</div>
|
||||
)
|
||||
@@ -681,15 +683,15 @@ function WorkflowInputMapperSyncWrapper({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center rounded-md border border-gray-600/50 bg-gray-900/20 p-8'>
|
||||
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
|
||||
<div className='flex items-center justify-center rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-8'>
|
||||
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputFields.length === 0) {
|
||||
return (
|
||||
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 text-sm'>
|
||||
<div className='rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-4 text-center text-[var(--text-muted)] text-sm'>
|
||||
This workflow has no custom input fields
|
||||
</div>
|
||||
)
|
||||
@@ -902,7 +904,22 @@ export function ToolInput({
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
|
||||
const { data: customTools = [] } = useCustomTools(workspaceId)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const selectedTools: StoredTool[] =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value[0] !== null &&
|
||||
typeof value[0]?.type === 'string'
|
||||
? (value as StoredTool[])
|
||||
: []
|
||||
|
||||
const hasReferenceOnlyCustomTools = selectedTools.some(
|
||||
(tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code
|
||||
)
|
||||
const shouldFetchCustomTools = !isPreview || hasReferenceOnlyCustomTools
|
||||
const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '')
|
||||
|
||||
const {
|
||||
mcpTools,
|
||||
@@ -918,24 +935,15 @@ export function ToolInput({
|
||||
const mcpDataLoading = mcpLoading || mcpServersLoading
|
||||
const hasRefreshedRef = useRef(false)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const selectedTools: StoredTool[] =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value[0] !== null &&
|
||||
typeof value[0]?.type === 'string'
|
||||
? (value as StoredTool[])
|
||||
: []
|
||||
|
||||
const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp')
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreview) return
|
||||
if (hasMcpTools && !hasRefreshedRef.current) {
|
||||
hasRefreshedRef.current = true
|
||||
forceRefreshMcpTools(workspaceId)
|
||||
}
|
||||
}, [hasMcpTools, forceRefreshMcpTools, workspaceId])
|
||||
}, [hasMcpTools, forceRefreshMcpTools, workspaceId, isPreview])
|
||||
|
||||
/**
|
||||
* Returns issue info for an MCP tool.
|
||||
@@ -1003,18 +1011,23 @@ export function ToolInput({
|
||||
const provider = model ? getProviderFromModel(model) : ''
|
||||
const supportsToolControl = provider ? supportsToolUsageControl(provider) : false
|
||||
|
||||
const toolBlocks = getAllBlocks().filter(
|
||||
(block) =>
|
||||
(block.category === 'tools' ||
|
||||
block.type === 'api' ||
|
||||
block.type === 'webhook_request' ||
|
||||
block.type === 'workflow' ||
|
||||
block.type === 'knowledge' ||
|
||||
block.type === 'function') &&
|
||||
block.type !== 'evaluator' &&
|
||||
block.type !== 'mcp' &&
|
||||
block.type !== 'file'
|
||||
)
|
||||
const { filterBlocks, config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
const toolBlocks = useMemo(() => {
|
||||
const allToolBlocks = getAllBlocks().filter(
|
||||
(block) =>
|
||||
(block.category === 'tools' ||
|
||||
block.type === 'api' ||
|
||||
block.type === 'webhook_request' ||
|
||||
block.type === 'workflow' ||
|
||||
block.type === 'knowledge' ||
|
||||
block.type === 'function') &&
|
||||
block.type !== 'evaluator' &&
|
||||
block.type !== 'mcp' &&
|
||||
block.type !== 'file'
|
||||
)
|
||||
return filterBlocks(allToolBlocks)
|
||||
}, [filterBlocks])
|
||||
|
||||
const customFilter = useCallback((value: string, search: string) => {
|
||||
if (!search.trim()) return 1
|
||||
@@ -1602,33 +1615,37 @@ export function ToolInput({
|
||||
const groups: ComboboxOptionGroup[] = []
|
||||
|
||||
// Actions group (no section header)
|
||||
groups.push({
|
||||
items: [
|
||||
{
|
||||
label: 'Create Tool',
|
||||
value: 'action-create-tool',
|
||||
icon: WrenchIcon,
|
||||
onSelect: () => {
|
||||
setCustomToolModalOpen(true)
|
||||
setOpen(false)
|
||||
},
|
||||
disabled: isPreview,
|
||||
const actionItems: ComboboxOption[] = []
|
||||
if (!permissionConfig.disableCustomTools) {
|
||||
actionItems.push({
|
||||
label: 'Create Tool',
|
||||
value: 'action-create-tool',
|
||||
icon: WrenchIcon,
|
||||
onSelect: () => {
|
||||
setCustomToolModalOpen(true)
|
||||
setOpen(false)
|
||||
},
|
||||
{
|
||||
label: 'Add MCP Server',
|
||||
value: 'action-add-mcp',
|
||||
icon: McpIcon,
|
||||
onSelect: () => {
|
||||
setOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } }))
|
||||
},
|
||||
disabled: isPreview,
|
||||
disabled: isPreview,
|
||||
})
|
||||
}
|
||||
if (!permissionConfig.disableMcpTools) {
|
||||
actionItems.push({
|
||||
label: 'Add MCP Server',
|
||||
value: 'action-add-mcp',
|
||||
icon: McpIcon,
|
||||
onSelect: () => {
|
||||
setOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } }))
|
||||
},
|
||||
],
|
||||
})
|
||||
disabled: isPreview,
|
||||
})
|
||||
}
|
||||
if (actionItems.length > 0) {
|
||||
groups.push({ items: actionItems })
|
||||
}
|
||||
|
||||
// Custom Tools section
|
||||
if (customTools.length > 0) {
|
||||
if (!permissionConfig.disableCustomTools && customTools.length > 0) {
|
||||
groups.push({
|
||||
section: 'Custom Tools',
|
||||
items: customTools.map((customTool) => ({
|
||||
@@ -1653,7 +1670,7 @@ export function ToolInput({
|
||||
}
|
||||
|
||||
// MCP Tools section
|
||||
if (availableMcpTools.length > 0) {
|
||||
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) {
|
||||
groups.push({
|
||||
section: 'MCP Tools',
|
||||
items: availableMcpTools.map((mcpTool) => {
|
||||
@@ -1730,6 +1747,8 @@ export function ToolInput({
|
||||
setStoreValue,
|
||||
handleMcpToolSelect,
|
||||
handleSelectTool,
|
||||
permissionConfig.disableCustomTools,
|
||||
permissionConfig.disableMcpTools,
|
||||
])
|
||||
|
||||
const toolRequiresOAuth = (toolId: string): boolean => {
|
||||
|
||||
@@ -43,10 +43,12 @@ export function TriggerSave({
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
|
||||
|
||||
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl'))
|
||||
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as
|
||||
| string
|
||||
| null
|
||||
const storedTestUrlExpiresAt = useSubBlockStore((state) =>
|
||||
state.getValue(blockId, 'testUrlExpiresAt')
|
||||
)
|
||||
) as string | null
|
||||
|
||||
const isTestUrlExpired = useMemo(() => {
|
||||
if (!storedTestUrlExpiresAt) return true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, Check, ChevronUp, Pencil, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
|
||||
import { BookOpen, Check, ChevronUp, Pencil, Settings } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
useEditorBlockProperties,
|
||||
useEditorSubblockLayout,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
@@ -58,9 +60,8 @@ export function Editor() {
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow display properties
|
||||
const subflowIcon = isSubflow && currentBlock.type === 'loop' ? RepeatIcon : SplitIcon
|
||||
const subflowBgColor = isSubflow && currentBlock.type === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
// Get subflow display properties from configs
|
||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
@@ -176,8 +177,9 @@ export function Editor() {
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
*/
|
||||
const handleOpenDocs = () => {
|
||||
if (blockConfig?.docsLink) {
|
||||
window.open(blockConfig.docsLink, '_blank', 'noopener,noreferrer')
|
||||
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
||||
if (docsLink) {
|
||||
window.open(docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,10 +197,10 @@ export function Editor() {
|
||||
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px]'
|
||||
style={{ background: isSubflow ? subflowBgColor : blockConfig?.bgColor }}
|
||||
style={{ background: isSubflow ? subflowConfig?.bgColor : blockConfig?.bgColor }}
|
||||
>
|
||||
<IconComponent
|
||||
icon={isSubflow ? subflowIcon : blockConfig?.icon}
|
||||
icon={isSubflow ? subflowConfig?.icon : blockConfig?.icon}
|
||||
className='h-[12px] w-[12px] text-[var(--white)]'
|
||||
/>
|
||||
</div>
|
||||
@@ -295,7 +297,7 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{currentBlock && !isSubflow && blockConfig?.docsLink && (
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
z-index: 9999;
|
||||
`
|
||||
|
||||
// Create icon container
|
||||
const iconContainer = document.createElement('div')
|
||||
iconContainer.style.cssText = `
|
||||
width: 24px;
|
||||
@@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
// Clone the actual icon if provided
|
||||
if (info.iconElement) {
|
||||
const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement
|
||||
clonedIcon.style.width = '16px'
|
||||
@@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
iconContainer.appendChild(clonedIcon)
|
||||
}
|
||||
|
||||
// Create text element
|
||||
const text = document.createElement('span')
|
||||
text.textContent = info.name
|
||||
text.style.cssText = `
|
||||
color: #FFFFFF;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { createDragPreview, type DragItemInfo } from './drag-preview'
|
||||
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
|
||||
interface ToolbarItemContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when add to canvas is clicked
|
||||
*/
|
||||
onAddToCanvas: () => void
|
||||
/**
|
||||
* Callback when view documentation is clicked
|
||||
*/
|
||||
onViewDocumentation?: () => void
|
||||
/**
|
||||
* Whether the view documentation option should be shown
|
||||
*/
|
||||
showViewDocumentation?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for toolbar items (triggers and blocks).
|
||||
* Displays options to add to canvas and view documentation.
|
||||
*/
|
||||
export function ToolbarItemContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onAddToCanvas,
|
||||
onViewDocumentation,
|
||||
showViewDocumentation = false,
|
||||
}: ToolbarItemContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onAddToCanvas()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add to canvas
|
||||
</PopoverItem>
|
||||
{showViewDocumentation && onViewDocumentation && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewDocumentation()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
View documentation
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getTriggersForSidebar,
|
||||
hasTriggerCapability,
|
||||
} from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components'
|
||||
import {
|
||||
calculateTriggerHeights,
|
||||
useToolbarItemInteractions,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useToolbarStore } from '@/stores/panel/toolbar/store'
|
||||
|
||||
interface BlockItem {
|
||||
@@ -34,6 +36,7 @@ interface BlockItem {
|
||||
config?: BlockConfig
|
||||
icon?: any
|
||||
bgColor?: string
|
||||
docsLink?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +101,7 @@ function getBlocks() {
|
||||
type: LoopTool.type,
|
||||
icon: LoopTool.icon,
|
||||
bgColor: LoopTool.bgColor,
|
||||
docsLink: LoopTool.docsLink,
|
||||
isSpecial: true,
|
||||
})
|
||||
|
||||
@@ -106,6 +110,7 @@ function getBlocks() {
|
||||
type: ParallelTool.type,
|
||||
icon: ParallelTool.icon,
|
||||
bgColor: ParallelTool.bgColor,
|
||||
docsLink: ParallelTool.docsLink,
|
||||
isSpecial: true,
|
||||
})
|
||||
|
||||
@@ -178,6 +183,16 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
// Toggle animation state
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
|
||||
// Context menu state
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [activeItemInfo, setActiveItemInfo] = useState<{
|
||||
type: string
|
||||
isTrigger: boolean
|
||||
docsLink?: string
|
||||
} | null>(null)
|
||||
|
||||
// Toolbar store
|
||||
const { toolbarTriggersHeight, setToolbarTriggersHeight, preSearchHeight, setPreSearchHeight } =
|
||||
useToolbarStore()
|
||||
@@ -192,9 +207,16 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
triggersHeaderRef,
|
||||
})
|
||||
|
||||
// Permission config for filtering
|
||||
const { filterBlocks } = usePermissionConfig()
|
||||
|
||||
// Get static data (computed once and cached)
|
||||
const triggers = getTriggers()
|
||||
const blocks = getBlocks()
|
||||
const allTriggers = getTriggers()
|
||||
const allBlocks = getBlocks()
|
||||
|
||||
// Apply permission-based filtering to blocks and triggers
|
||||
const blocks = useMemo(() => filterBlocks(allBlocks), [filterBlocks, allBlocks])
|
||||
const triggers = useMemo(() => filterBlocks(allTriggers), [filterBlocks, allTriggers])
|
||||
|
||||
// Determine if triggers are at minimum height (blocks are fully expanded)
|
||||
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD
|
||||
@@ -338,6 +360,68 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
setIsToggling(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle context menu for toolbar items
|
||||
*/
|
||||
const handleItemContextMenu = useCallback(
|
||||
(e: React.MouseEvent, type: string, isTrigger: boolean, docsLink?: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setActiveItemInfo({ type, isTrigger, docsLink })
|
||||
setIsContextMenuOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Close context menu and clear active item state
|
||||
*/
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setIsContextMenuOpen(false)
|
||||
setActiveItemInfo(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle add to canvas from context menu
|
||||
*/
|
||||
const handleContextMenuAddToCanvas = useCallback(() => {
|
||||
if (activeItemInfo) {
|
||||
handleItemClick(activeItemInfo.type, activeItemInfo.isTrigger)
|
||||
}
|
||||
}, [activeItemInfo, handleItemClick])
|
||||
|
||||
/**
|
||||
* Handle view documentation from context menu
|
||||
*/
|
||||
const handleViewDocumentation = useCallback(() => {
|
||||
if (activeItemInfo?.docsLink) {
|
||||
window.open(activeItemInfo.docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}, [activeItemInfo])
|
||||
|
||||
/**
|
||||
* Handle clicks outside the context menu to close it
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isContextMenuOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [isContextMenuOpen, closeContextMenu])
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation with ArrowUp / ArrowDown when the toolbar tab
|
||||
* is active and search is open (e.g. after Mod+F). Navigation order:
|
||||
@@ -553,6 +637,9 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
})
|
||||
}}
|
||||
onClick={() => handleItemClick(trigger.type, isTriggerCapable)}
|
||||
onContextMenu={(e) =>
|
||||
handleItemContextMenu(e, trigger.type, isTriggerCapable, trigger.docsLink)
|
||||
}
|
||||
className={clsx(
|
||||
'group flex h-[28px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]',
|
||||
'cursor-pointer hover:bg-[var(--surface-6)] active:cursor-grabbing dark:hover:bg-[var(--surface-5)]',
|
||||
@@ -642,6 +729,14 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
document.body.classList.remove('sim-drag-subflow')
|
||||
}}
|
||||
onClick={() => handleItemClick(block.type, false)}
|
||||
onContextMenu={(e) =>
|
||||
handleItemContextMenu(
|
||||
e,
|
||||
block.type,
|
||||
false,
|
||||
block.docsLink ?? block.config?.docsLink
|
||||
)
|
||||
}
|
||||
className={clsx(
|
||||
'group flex h-[28px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]',
|
||||
'cursor-pointer hover:bg-[var(--surface-6)] active:cursor-grabbing dark:hover:bg-[var(--surface-5)]',
|
||||
@@ -685,6 +780,17 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Item Context Menu */}
|
||||
<ToolbarItemContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
onAddToCanvas={handleContextMenuAddToCanvas}
|
||||
onViewDocumentation={handleViewDocumentation}
|
||||
showViewDocumentation={Boolean(activeItemInfo?.docsLink)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
/**
|
||||
* Simplified hook that uses React Query for usage limits.
|
||||
* Provides usage exceeded status from existing subscription data.
|
||||
@@ -12,7 +15,7 @@ export function useUsageLimits(options?: {
|
||||
}) {
|
||||
// For now, we only support user context via React Query
|
||||
// Organization context should use useOrganizationBilling directly
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
|
||||
const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user