feat(chat-streaming): added a stream option to workflow execute route, updated SDKs, updated docs (#1565)

* feat(chat-stream): updated workflow id execute route to support streaming via API

* enable streaming via api

* added only text stream option

* cleanup deployed preview componnet

* updated selectedOutputIds to selectedOutput

* updated TS and Python SDKs with async, rate limits, usage, and streaming API routes

* stream non-streaming blocks when streaming is specified

* fix(chat-panel): add onBlockComplete handler to chat panel to stream back blocks as they complete

* update docs

* cleanup

* ack PR comments

* updated next config

* removed getAssetUrl in favor of local assets

* resolve merge conflicts

* remove extra logic to create sensitive result

* simplify internal auth

* remove vercel blob from CSP + next config
This commit is contained in:
Waleed
2025-10-07 15:10:37 -07:00
committed by GitHub
parent a63a7b0262
commit 872e034312
73 changed files with 3662 additions and 1618 deletions

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef } from 'react'
import { getVideoUrl } from '@/lib/utils'
import { getAssetUrl } from '@/lib/utils'
interface LightboxProps {
isOpen: boolean
@@ -60,7 +60,7 @@ export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
/>
) : (
<video
src={getVideoUrl(src)}
src={getAssetUrl(src)}
autoPlay
loop
muted

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { getVideoUrl } from '@/lib/utils'
import { getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
interface VideoProps {
@@ -39,7 +39,7 @@ export function Video({
muted={muted}
playsInline={playsInline}
className={`${className} ${enableLightbox ? 'cursor-pointer transition-opacity hover:opacity-90' : ''}`}
src={getVideoUrl(src)}
src={getAssetUrl(src)}
onClick={handleVideoClick}
/>

View File

@@ -214,7 +214,7 @@ class SimStudioError(Exception):
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def run_workflow():
try:
@@ -252,7 +252,7 @@ Behandeln Sie verschiedene Fehlertypen, die während der Workflow-Ausführung au
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_error_handling():
try:
@@ -284,7 +284,7 @@ from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
@@ -298,7 +298,7 @@ Führen Sie mehrere Workflows effizient aus:
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
@@ -352,8 +352,8 @@ Konfigurieren Sie den Client mit Umgebungsvariablen:
# Development configuration
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
@@ -365,13 +365,13 @@ Konfigurieren Sie den Client mit Umgebungsvariablen:
from simstudio import SimStudioClient
# Production configuration with error handling
api_key = os.getenv("SIMSTUDIO_API_KEY")
api_key = os.getenv("SIM_API_KEY")
if not api_key:
raise ValueError("SIMSTUDIO_API_KEY environment variable is required")
raise ValueError("SIM_API_KEY environment variable is required")
client = SimStudioClient(
api_key=api_key,
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```

View File

@@ -230,7 +230,7 @@ class SimStudioError extends Error {
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function runWorkflow() {
@@ -271,7 +271,7 @@ Behandeln Sie verschiedene Fehlertypen, die während der Workflow-Ausführung au
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function executeWithErrorHandling() {
@@ -315,14 +315,14 @@ Konfigurieren Sie den Client mit Umgebungsvariablen:
import { SimStudioClient } from 'simstudio-ts-sdk';
// Development configuration
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
baseUrl: process.env.SIM_BASE_URL // optional
});
```
@@ -333,14 +333,14 @@ Konfigurieren Sie den Client mit Umgebungsvariablen:
import { SimStudioClient } from 'simstudio-ts-sdk';
// Production configuration with validation
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://sim.ai'
baseUrl: process.env.SIM_BASE_URL || 'https://sim.ai'
});
```
@@ -357,7 +357,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
const app = express();
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
app.use(express.json());
@@ -399,7 +399,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
export default async function handler(
@@ -476,7 +476,7 @@ import { useState, useCallback } from 'react';
import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY!
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
});
interface UseWorkflowResult {
@@ -588,7 +588,7 @@ import {
// Type-safe client initialization
const client: SimStudioClient = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
// Type-safe workflow execution

View File

@@ -10,7 +10,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
The official Python SDK for Sim allows you to execute workflows programmatically from your Python applications using the official Python SDK.
<Callout type="info">
The Python SDK supports Python 3.8+ and provides synchronous workflow execution. All workflow executions are currently synchronous.
The Python SDK supports Python 3.8+ with async execution support, automatic rate limiting with exponential backoff, and usage tracking.
</Callout>
## Installation
@@ -74,8 +74,13 @@ result = client.execute_workflow(
- `workflow_id` (str): The ID of the workflow to execute
- `input_data` (dict, optional): Input data to pass to the workflow
- `timeout` (float, optional): Timeout in seconds (default: 30.0)
- `stream` (bool, optional): Enable streaming responses (default: False)
- `selected_outputs` (list[str], optional): Block outputs to stream in `blockName.attribute` format (e.g., `["agent1.content"]`)
- `async_execution` (bool, optional): Execute asynchronously (default: False)
**Returns:** `WorkflowExecutionResult`
**Returns:** `WorkflowExecutionResult | AsyncExecutionResult`
When `async_execution=True`, returns immediately with a task ID for polling. Otherwise, waits for completion.
##### get_workflow_status()
@@ -107,28 +112,117 @@ if is_ready:
**Returns:** `bool`
##### execute_workflow_sync()
##### get_job_status()
<Callout type="info">
Currently, this method is identical to `execute_workflow()` since all executions are synchronous. This method is provided for future compatibility when asynchronous execution is added.
</Callout>
Execute a workflow (currently synchronous, same as `execute_workflow()`).
Get the status of an async job execution.
```python
result = client.execute_workflow_sync(
status = client.get_job_status("task-id-from-async-execution")
print("Status:", status["status"]) # 'queued', 'processing', 'completed', 'failed'
if status["status"] == "completed":
print("Output:", status["output"])
```
**Parameters:**
- `task_id` (str): The task ID returned from async execution
**Returns:** `Dict[str, Any]`
**Response fields:**
- `success` (bool): Whether the request was successful
- `taskId` (str): The task ID
- `status` (str): One of `'queued'`, `'processing'`, `'completed'`, `'failed'`, `'cancelled'`
- `metadata` (dict): Contains `startedAt`, `completedAt`, and `duration`
- `output` (any, optional): The workflow output (when completed)
- `error` (any, optional): Error details (when failed)
- `estimatedDuration` (int, optional): Estimated duration in milliseconds (when processing/queued)
##### execute_with_retry()
Execute a workflow with automatic retry on rate limit errors using exponential backoff.
```python
result = client.execute_with_retry(
"workflow-id",
input_data={"data": "some input"},
timeout=60.0
input_data={"message": "Hello"},
timeout=30.0,
max_retries=3, # Maximum number of retries
initial_delay=1.0, # Initial delay in seconds
max_delay=30.0, # Maximum delay in seconds
backoff_multiplier=2.0 # Exponential backoff multiplier
)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
- `input_data` (dict, optional): Input data to pass to the workflow
- `timeout` (float): Timeout for the initial request in seconds
- `timeout` (float, optional): Timeout in seconds
- `stream` (bool, optional): Enable streaming responses
- `selected_outputs` (list, optional): Block outputs to stream
- `async_execution` (bool, optional): Execute asynchronously
- `max_retries` (int, optional): Maximum number of retries (default: 3)
- `initial_delay` (float, optional): Initial delay in seconds (default: 1.0)
- `max_delay` (float, optional): Maximum delay in seconds (default: 30.0)
- `backoff_multiplier` (float, optional): Backoff multiplier (default: 2.0)
**Returns:** `WorkflowExecutionResult`
**Returns:** `WorkflowExecutionResult | AsyncExecutionResult`
The retry logic uses exponential backoff (1s → 2s → 4s → 8s...) with ±25% jitter to prevent thundering herd. If the API provides a `retry-after` header, it will be used instead.
##### get_rate_limit_info()
Get the current rate limit information from the last API response.
```python
rate_limit_info = client.get_rate_limit_info()
if rate_limit_info:
print("Limit:", rate_limit_info.limit)
print("Remaining:", rate_limit_info.remaining)
print("Reset:", datetime.fromtimestamp(rate_limit_info.reset))
```
**Returns:** `RateLimitInfo | None`
##### get_usage_limits()
Get current usage limits and quota information for your account.
```python
limits = client.get_usage_limits()
print("Sync requests remaining:", limits.rate_limit["sync"]["remaining"])
print("Async requests remaining:", limits.rate_limit["async"]["remaining"])
print("Current period cost:", limits.usage["currentPeriodCost"])
print("Plan:", limits.usage["plan"])
```
**Returns:** `UsageLimits`
**Response structure:**
```python
{
"success": bool,
"rateLimit": {
"sync": {
"isLimited": bool,
"limit": int,
"remaining": int,
"resetAt": str
},
"async": {
"isLimited": bool,
"limit": int,
"remaining": int,
"resetAt": str
},
"authType": str # 'api' or 'manual'
},
"usage": {
"currentPeriodCost": float,
"limit": float,
"plan": str # e.g., 'free', 'pro'
}
}
```
##### set_api_key()
@@ -170,6 +264,18 @@ class WorkflowExecutionResult:
total_duration: Optional[float] = None
```
### AsyncExecutionResult
```python
@dataclass
class AsyncExecutionResult:
success: bool
task_id: str
status: str # 'queued'
created_at: str
links: Dict[str, str] # e.g., {"status": "/api/jobs/{taskId}"}
```
### WorkflowStatus
```python
@@ -181,6 +287,27 @@ class WorkflowStatus:
needs_redeployment: bool = False
```
### RateLimitInfo
```python
@dataclass
class RateLimitInfo:
limit: int
remaining: int
reset: int
retry_after: Optional[int] = None
```
### UsageLimits
```python
@dataclass
class UsageLimits:
success: bool
rate_limit: Dict[str, Any]
usage: Dict[str, Any]
```
### SimStudioError
```python
@@ -191,6 +318,13 @@ class SimStudioError(Exception):
self.status = status
```
**Common error codes:**
- `UNAUTHORIZED`: Invalid API key
- `TIMEOUT`: Request timed out
- `RATE_LIMIT_EXCEEDED`: Rate limit exceeded
- `USAGE_LIMIT_EXCEEDED`: Usage limit exceeded
- `EXECUTION_ERROR`: Workflow execution failed
## Examples
### Basic Workflow Execution
@@ -214,7 +348,7 @@ class SimStudioError(Exception):
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY
def run_workflow():
try:
@@ -252,7 +386,7 @@ Handle different types of errors that may occur during workflow execution:
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY")
def execute_with_error_handling():
try:
@@ -284,7 +418,7 @@ from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
@@ -298,7 +432,7 @@ Execute multiple workflows efficiently:
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY")
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
@@ -339,6 +473,230 @@ for result in results:
print(f"Workflow {result['workflow_id']}: {'Success' if result['success'] else 'Failed'}")
```
### Async Workflow Execution
Execute workflows asynchronously for long-running tasks:
```python
import os
import time
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_async():
try:
# Start async execution
result = client.execute_workflow(
"workflow-id",
input_data={"data": "large dataset"},
async_execution=True # Execute asynchronously
)
# Check if result is an async execution
if hasattr(result, 'task_id'):
print(f"Task ID: {result.task_id}")
print(f"Status endpoint: {result.links['status']}")
# Poll for completion
status = client.get_job_status(result.task_id)
while status["status"] in ["queued", "processing"]:
print(f"Current status: {status['status']}")
time.sleep(2) # Wait 2 seconds
status = client.get_job_status(result.task_id)
if status["status"] == "completed":
print("Workflow completed!")
print(f"Output: {status['output']}")
print(f"Duration: {status['metadata']['duration']}")
else:
print(f"Workflow failed: {status['error']}")
except Exception as error:
print(f"Error: {error}")
execute_async()
```
### Rate Limiting and Retry
Handle rate limits automatically with exponential backoff:
```python
import os
from simstudio import SimStudioClient, SimStudioError
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_retry_handling():
try:
# Automatically retries on rate limit
result = client.execute_with_retry(
"workflow-id",
input_data={"message": "Process this"},
max_retries=5,
initial_delay=1.0,
max_delay=60.0,
backoff_multiplier=2.0
)
print(f"Success: {result}")
except SimStudioError as error:
if error.code == "RATE_LIMIT_EXCEEDED":
print("Rate limit exceeded after all retries")
# Check rate limit info
rate_limit_info = client.get_rate_limit_info()
if rate_limit_info:
from datetime import datetime
reset_time = datetime.fromtimestamp(rate_limit_info.reset)
print(f"Rate limit resets at: {reset_time}")
execute_with_retry_handling()
```
### Usage Monitoring
Monitor your account usage and limits:
```python
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def check_usage():
try:
limits = client.get_usage_limits()
print("=== Rate Limits ===")
print("Sync requests:")
print(f" Limit: {limits.rate_limit['sync']['limit']}")
print(f" Remaining: {limits.rate_limit['sync']['remaining']}")
print(f" Resets at: {limits.rate_limit['sync']['resetAt']}")
print(f" Is limited: {limits.rate_limit['sync']['isLimited']}")
print("\nAsync requests:")
print(f" Limit: {limits.rate_limit['async']['limit']}")
print(f" Remaining: {limits.rate_limit['async']['remaining']}")
print(f" Resets at: {limits.rate_limit['async']['resetAt']}")
print(f" Is limited: {limits.rate_limit['async']['isLimited']}")
print("\n=== Usage ===")
print(f"Current period cost: ${limits.usage['currentPeriodCost']:.2f}")
print(f"Limit: ${limits.usage['limit']:.2f}")
print(f"Plan: {limits.usage['plan']}")
percent_used = (limits.usage['currentPeriodCost'] / limits.usage['limit']) * 100
print(f"Usage: {percent_used:.1f}%")
if percent_used > 80:
print("⚠️ Warning: You are approaching your usage limit!")
except Exception as error:
print(f"Error checking usage: {error}")
check_usage()
```
### Streaming Workflow Execution
Execute workflows with real-time streaming responses:
```python
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY")
def execute_with_streaming():
"""Execute workflow with streaming enabled."""
try:
# Enable streaming for specific block outputs
result = client.execute_workflow(
"workflow-id",
input_data={"message": "Count to five"},
stream=True,
selected_outputs=["agent1.content"] # Use blockName.attribute format
)
print("Workflow result:", result)
except Exception as error:
print("Error:", error)
execute_with_streaming()
```
The streaming response follows the Server-Sent Events (SSE) format:
```
data: {"blockId":"7b7735b9-19e5-4bd6-818b-46aae2596e9f","chunk":"One"}
data: {"blockId":"7b7735b9-19e5-4bd6-818b-46aae2596e9f","chunk":", two"}
data: {"event":"done","success":true,"output":{},"metadata":{"duration":610}}
data: [DONE]
```
**Flask Streaming Example:**
```python
from flask import Flask, Response, stream_with_context
import requests
import json
import os
app = Flask(__name__)
@app.route('/stream-workflow')
def stream_workflow():
"""Stream workflow execution to the client."""
def generate():
response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY
},
json={
'message': 'Generate a story',
'stream': True,
'selectedOutputs': ['agent1.content']
},
stream=True
)
for line in response.iter_lines():
if line:
decoded_line = line.decode('utf-8')
if decoded_line.startswith('data: '):
data = decoded_line[6:] # Remove 'data: ' prefix
if data == '[DONE]':
break
try:
parsed = json.loads(data)
if 'chunk' in parsed:
yield f"data: {json.dumps(parsed)}\n\n"
elif parsed.get('event') == 'done':
yield f"data: {json.dumps(parsed)}\n\n"
print("Execution complete:", parsed.get('metadata'))
except json.JSONDecodeError:
pass
return Response(
stream_with_context(generate()),
mimetype='text/event-stream'
)
if __name__ == '__main__':
app.run(debug=True)
```
### Environment Configuration
Configure the client using environment variables:
@@ -351,8 +709,8 @@ Configure the client using environment variables:
# Development configuration
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
api_key=os.getenv("SIM_API_KEY")
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
</Tab>
@@ -362,13 +720,13 @@ Configure the client using environment variables:
from simstudio import SimStudioClient
# Production configuration with error handling
api_key = os.getenv("SIMSTUDIO_API_KEY")
api_key = os.getenv("SIM_API_KEY")
if not api_key:
raise ValueError("SIMSTUDIO_API_KEY environment variable is required")
raise ValueError("SIM_API_KEY environment variable is required")
client = SimStudioClient(
api_key=api_key,
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
</Tab>

View File

@@ -7,10 +7,10 @@ import { Card, Cards } from 'fumadocs-ui/components/card'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
The official TypeScript/JavaScript SDK for Sim provides full type safety and supports both Node.js and browser environments, allowing you to execute workflows programmatically from your Node.js applications, web applications, and other JavaScript environments. All workflow executions are currently synchronous.
The official TypeScript/JavaScript SDK for Sim provides full type safety and supports both Node.js and browser environments, allowing you to execute workflows programmatically from your Node.js applications, web applications, and other JavaScript environments.
<Callout type="info">
The TypeScript SDK provides full type safety and supports both Node.js and browser environments. All workflow executions are currently synchronous.
The TypeScript SDK provides full type safety, async execution support, automatic rate limiting with exponential backoff, and usage tracking.
</Callout>
## Installation
@@ -89,8 +89,13 @@ const result = await client.executeWorkflow('workflow-id', {
- `options` (ExecutionOptions, optional):
- `input` (any): Input data to pass to the workflow
- `timeout` (number): Timeout in milliseconds (default: 30000)
- `stream` (boolean): Enable streaming responses (default: false)
- `selectedOutputs` (string[]): Block outputs to stream in `blockName.attribute` format (e.g., `["agent1.content"]`)
- `async` (boolean): Execute asynchronously (default: false)
**Returns:** `Promise<WorkflowExecutionResult>`
**Returns:** `Promise<WorkflowExecutionResult | AsyncExecutionResult>`
When `async: true`, returns immediately with a task ID for polling. Otherwise, waits for completion.
##### getWorkflowStatus()
@@ -122,28 +127,116 @@ if (isReady) {
**Returns:** `Promise<boolean>`
##### executeWorkflowSync()
##### getJobStatus()
<Callout type="info">
Currently, this method is identical to `executeWorkflow()` since all executions are synchronous. This method is provided for future compatibility when asynchronous execution is added.
</Callout>
Execute a workflow (currently synchronous, same as `executeWorkflow()`).
Get the status of an async job execution.
```typescript
const result = await client.executeWorkflowSync('workflow-id', {
input: { data: 'some input' },
timeout: 60000
const status = await client.getJobStatus('task-id-from-async-execution');
console.log('Status:', status.status); // 'queued', 'processing', 'completed', 'failed'
if (status.status === 'completed') {
console.log('Output:', status.output);
}
```
**Parameters:**
- `taskId` (string): The task ID returned from async execution
**Returns:** `Promise<JobStatus>`
**Response fields:**
- `success` (boolean): Whether the request was successful
- `taskId` (string): The task ID
- `status` (string): One of `'queued'`, `'processing'`, `'completed'`, `'failed'`, `'cancelled'`
- `metadata` (object): Contains `startedAt`, `completedAt`, and `duration`
- `output` (any, optional): The workflow output (when completed)
- `error` (any, optional): Error details (when failed)
- `estimatedDuration` (number, optional): Estimated duration in milliseconds (when processing/queued)
##### executeWithRetry()
Execute a workflow with automatic retry on rate limit errors using exponential backoff.
```typescript
const result = await client.executeWithRetry('workflow-id', {
input: { message: 'Hello' },
timeout: 30000
}, {
maxRetries: 3, // Maximum number of retries
initialDelay: 1000, // Initial delay in ms (1 second)
maxDelay: 30000, // Maximum delay in ms (30 seconds)
backoffMultiplier: 2 // Exponential backoff multiplier
});
```
**Parameters:**
- `workflowId` (string): The ID of the workflow to execute
- `options` (ExecutionOptions, optional):
- `input` (any): Input data to pass to the workflow
- `timeout` (number): Timeout for the initial request in milliseconds
- `options` (ExecutionOptions, optional): Same as `executeWorkflow()`
- `retryOptions` (RetryOptions, optional):
- `maxRetries` (number): Maximum number of retries (default: 3)
- `initialDelay` (number): Initial delay in ms (default: 1000)
- `maxDelay` (number): Maximum delay in ms (default: 30000)
- `backoffMultiplier` (number): Backoff multiplier (default: 2)
**Returns:** `Promise<WorkflowExecutionResult>`
**Returns:** `Promise<WorkflowExecutionResult | AsyncExecutionResult>`
The retry logic uses exponential backoff (1s → 2s → 4s → 8s...) with ±25% jitter to prevent thundering herd. If the API provides a `retry-after` header, it will be used instead.
##### getRateLimitInfo()
Get the current rate limit information from the last API response.
```typescript
const rateLimitInfo = client.getRateLimitInfo();
if (rateLimitInfo) {
console.log('Limit:', rateLimitInfo.limit);
console.log('Remaining:', rateLimitInfo.remaining);
console.log('Reset:', new Date(rateLimitInfo.reset * 1000));
}
```
**Returns:** `RateLimitInfo | null`
##### getUsageLimits()
Get current usage limits and quota information for your account.
```typescript
const limits = await client.getUsageLimits();
console.log('Sync requests remaining:', limits.rateLimit.sync.remaining);
console.log('Async requests remaining:', limits.rateLimit.async.remaining);
console.log('Current period cost:', limits.usage.currentPeriodCost);
console.log('Plan:', limits.usage.plan);
```
**Returns:** `Promise<UsageLimits>`
**Response structure:**
```typescript
{
success: boolean
rateLimit: {
sync: {
isLimited: boolean
limit: number
remaining: number
resetAt: string
}
async: {
isLimited: boolean
limit: number
remaining: number
resetAt: string
}
authType: string // 'api' or 'manual'
}
usage: {
currentPeriodCost: number
limit: number
plan: string // e.g., 'free', 'pro'
}
}
```
##### setApiKey()
@@ -181,6 +274,20 @@ interface WorkflowExecutionResult {
}
```
### AsyncExecutionResult
```typescript
interface AsyncExecutionResult {
success: boolean;
taskId: string;
status: 'queued';
createdAt: string;
links: {
status: string; // e.g., "/api/jobs/{taskId}"
};
}
```
### WorkflowStatus
```typescript
@@ -192,6 +299,45 @@ interface WorkflowStatus {
}
```
### RateLimitInfo
```typescript
interface RateLimitInfo {
limit: number;
remaining: number;
reset: number;
retryAfter?: number;
}
```
### UsageLimits
```typescript
interface UsageLimits {
success: boolean;
rateLimit: {
sync: {
isLimited: boolean;
limit: number;
remaining: number;
resetAt: string;
};
async: {
isLimited: boolean;
limit: number;
remaining: number;
resetAt: string;
};
authType: string;
};
usage: {
currentPeriodCost: number;
limit: number;
plan: string;
};
}
```
### SimStudioError
```typescript
@@ -201,6 +347,13 @@ class SimStudioError extends Error {
}
```
**Common error codes:**
- `UNAUTHORIZED`: Invalid API key
- `TIMEOUT`: Request timed out
- `RATE_LIMIT_EXCEEDED`: Rate limit exceeded
- `USAGE_LIMIT_EXCEEDED`: Usage limit exceeded
- `EXECUTION_ERROR`: Workflow execution failed
## Examples
### Basic Workflow Execution
@@ -224,7 +377,7 @@ class SimStudioError extends Error {
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function runWorkflow() {
@@ -265,7 +418,7 @@ Handle different types of errors that may occur during workflow execution:
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function executeWithErrorHandling() {
@@ -308,14 +461,14 @@ Configure the client using environment variables:
import { SimStudioClient } from 'simstudio-ts-sdk';
// Development configuration
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
baseUrl: process.env.SIM_BASE_URL // optional
});
```
</Tab>
@@ -324,14 +477,14 @@ Configure the client using environment variables:
import { SimStudioClient } from 'simstudio-ts-sdk';
// Production configuration with validation
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://sim.ai'
baseUrl: process.env.SIM_BASE_URL || 'https://sim.ai'
});
```
</Tab>
@@ -347,7 +500,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
const app = express();
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
app.use(express.json());
@@ -389,7 +542,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
export default async function handler(
@@ -466,7 +619,7 @@ import { useState, useCallback } from 'react';
import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
interface UseWorkflowResult {
@@ -522,7 +675,7 @@ function WorkflowComponent() {
<button onClick={handleExecute} disabled={loading}>
{loading ? 'Executing...' : 'Execute Workflow'}
</button>
{error && <div>Error: {error.message}</div>}
{result && (
<div>
@@ -535,6 +688,251 @@ function WorkflowComponent() {
}
```
### Async Workflow Execution
Execute workflows asynchronously for long-running tasks:
```typescript
import { SimStudioClient, AsyncExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIM_API_KEY!
});
async function executeAsync() {
try {
// Start async execution
const result = await client.executeWorkflow('workflow-id', {
input: { data: 'large dataset' },
async: true // Execute asynchronously
});
// Check if result is an async execution
if ('taskId' in result) {
console.log('Task ID:', result.taskId);
console.log('Status endpoint:', result.links.status);
// Poll for completion
let status = await client.getJobStatus(result.taskId);
while (status.status === 'queued' || status.status === 'processing') {
console.log('Current status:', status.status);
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds
status = await client.getJobStatus(result.taskId);
}
if (status.status === 'completed') {
console.log('Workflow completed!');
console.log('Output:', status.output);
console.log('Duration:', status.metadata.duration);
} else {
console.error('Workflow failed:', status.error);
}
}
} catch (error) {
console.error('Error:', error);
}
}
executeAsync();
```
### Rate Limiting and Retry
Handle rate limits automatically with exponential backoff:
```typescript
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIM_API_KEY!
});
async function executeWithRetryHandling() {
try {
// Automatically retries on rate limit
const result = await client.executeWithRetry('workflow-id', {
input: { message: 'Process this' }
}, {
maxRetries: 5,
initialDelay: 1000,
maxDelay: 60000,
backoffMultiplier: 2
});
console.log('Success:', result);
} catch (error) {
if (error instanceof SimStudioError && error.code === 'RATE_LIMIT_EXCEEDED') {
console.error('Rate limit exceeded after all retries');
// Check rate limit info
const rateLimitInfo = client.getRateLimitInfo();
if (rateLimitInfo) {
console.log('Rate limit resets at:', new Date(rateLimitInfo.reset * 1000));
}
}
}
}
```
### Usage Monitoring
Monitor your account usage and limits:
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIM_API_KEY!
});
async function checkUsage() {
try {
const limits = await client.getUsageLimits();
console.log('=== Rate Limits ===');
console.log('Sync requests:');
console.log(' Limit:', limits.rateLimit.sync.limit);
console.log(' Remaining:', limits.rateLimit.sync.remaining);
console.log(' Resets at:', limits.rateLimit.sync.resetAt);
console.log(' Is limited:', limits.rateLimit.sync.isLimited);
console.log('\nAsync requests:');
console.log(' Limit:', limits.rateLimit.async.limit);
console.log(' Remaining:', limits.rateLimit.async.remaining);
console.log(' Resets at:', limits.rateLimit.async.resetAt);
console.log(' Is limited:', limits.rateLimit.async.isLimited);
console.log('\n=== Usage ===');
console.log('Current period cost: $' + limits.usage.currentPeriodCost.toFixed(2));
console.log('Limit: $' + limits.usage.limit.toFixed(2));
console.log('Plan:', limits.usage.plan);
const percentUsed = (limits.usage.currentPeriodCost / limits.usage.limit) * 100;
console.log('Usage: ' + percentUsed.toFixed(1) + '%');
if (percentUsed > 80) {
console.warn('⚠️ Warning: You are approaching your usage limit!');
}
} catch (error) {
console.error('Error checking usage:', error);
}
}
checkUsage();
```
### Streaming Workflow Execution
Execute workflows with real-time streaming responses:
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIM_API_KEY!
});
async function executeWithStreaming() {
try {
// Enable streaming for specific block outputs
const result = await client.executeWorkflow('workflow-id', {
input: { message: 'Count to five' },
stream: true,
selectedOutputs: ['agent1.content'] // Use blockName.attribute format
});
console.log('Workflow result:', result);
} catch (error) {
console.error('Error:', error);
}
}
```
The streaming response follows the Server-Sent Events (SSE) format:
```
data: {"blockId":"7b7735b9-19e5-4bd6-818b-46aae2596e9f","chunk":"One"}
data: {"blockId":"7b7735b9-19e5-4bd6-818b-46aae2596e9f","chunk":", two"}
data: {"event":"done","success":true,"output":{},"metadata":{"duration":610}}
data: [DONE]
```
**React Streaming Example:**
```typescript
import { useState, useEffect } from 'react';
function StreamingWorkflow() {
const [output, setOutput] = useState('');
const [loading, setLoading] = useState(false);
const executeStreaming = async () => {
setLoading(true);
setOutput('');
// IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only
},
body: JSON.stringify({
message: 'Generate a story',
stream: true,
selectedOutputs: ['agent1.content']
})
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
setLoading(false);
break;
}
try {
const parsed = JSON.parse(data);
if (parsed.chunk) {
setOutput(prev => prev + parsed.chunk);
} else if (parsed.event === 'done') {
console.log('Execution complete:', parsed.metadata);
}
} catch (e) {
// Skip invalid JSON
}
}
}
}
};
return (
<div>
<button onClick={executeStreaming} disabled={loading}>
{loading ? 'Generating...' : 'Start Streaming'}
</button>
<div style={{ whiteSpace: 'pre-wrap' }}>{output}</div>
</div>
);
}
```
## Getting Your API Key
<Steps>
@@ -578,7 +976,7 @@ import {
// Type-safe client initialization
const client: SimStudioClient = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
// Type-safe workflow execution

View File

@@ -38,6 +38,84 @@ curl -X POST \
Successful responses return the serialized execution result from the Executor. Errors surface validation, auth, or workflow failures.
## Streaming Responses
Enable real-time streaming to receive workflow output as it's generated, character-by-character. This is useful for displaying AI responses progressively to users.
### Request Parameters
Add these parameters to enable streaming:
- `stream` - Set to `true` to enable Server-Sent Events (SSE) streaming
- `selectedOutputs` - Array of block outputs to stream (e.g., `["agent1.content"]`)
### Block Output Format
Use the `blockName.attribute` format to specify which block outputs to stream:
- Format: `"blockName.attribute"` (e.g., If you want to stream the content of the Agent 1 block, you would use `"agent1.content"`)
- Block names are case-insensitive and spaces are ignored
### Example Request
```bash
curl -X POST \
https://sim.ai/api/workflows/WORKFLOW_ID/execute \
-H 'Content-Type: application/json' \
-H 'X-API-Key: YOUR_KEY' \
-d '{
"message": "Count to five",
"stream": true,
"selectedOutputs": ["agent1.content"]
}'
```
### Response Format
Streaming responses use Server-Sent Events (SSE) format:
```
data: {"blockId":"7b7735b9-19e5-4bd6-818b-46aae2596e9f","chunk":"One"}
data: {"blockId":"7b7735b9-19e5-4bd6-818b-46aae2596e9f","chunk":", two"}
data: {"blockId":"7b7735b9-19e5-4bd6-818b-46aae2596e9f","chunk":", three"}
data: {"event":"done","success":true,"output":{},"metadata":{"duration":610}}
data: [DONE]
```
Each event includes:
- **Streaming chunks**: `{"blockId": "...", "chunk": "text"}` - Real-time text as it's generated
- **Final event**: `{"event": "done", ...}` - Execution metadata and complete results
- **Terminator**: `[DONE]` - Signals end of stream
### Multiple Block Streaming
When `selectedOutputs` includes multiple blocks, each chunk indicates which block produced it:
```bash
curl -X POST \
https://sim.ai/api/workflows/WORKFLOW_ID/execute \
-H 'Content-Type: application/json' \
-H 'X-API-Key: YOUR_KEY' \
-d '{
"message": "Process this request",
"stream": true,
"selectedOutputs": ["agent1.content", "agent2.content"]
}'
```
The `blockId` field in each chunk lets you route output to the correct UI element:
```
data: {"blockId":"agent1-uuid","chunk":"Processing..."}
data: {"blockId":"agent2-uuid","chunk":"Analyzing..."}
data: {"blockId":"agent1-uuid","chunk":" complete"}
```
## Output Reference
| Reference | Description |

View File

@@ -214,7 +214,7 @@ class SimStudioError(Exception):
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def run_workflow():
try:
@@ -252,7 +252,7 @@ Maneja diferentes tipos de errores que pueden ocurrir durante la ejecución del
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_error_handling():
try:
@@ -284,7 +284,7 @@ from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
@@ -298,7 +298,7 @@ Ejecuta múltiples flujos de trabajo de manera eficiente:
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
@@ -352,8 +352,8 @@ Configura el cliente usando variables de entorno:
# Development configuration
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
@@ -365,13 +365,13 @@ Configura el cliente usando variables de entorno:
from simstudio import SimStudioClient
# Production configuration with error handling
api_key = os.getenv("SIMSTUDIO_API_KEY")
api_key = os.getenv("SIM_API_KEY")
if not api_key:
raise ValueError("SIMSTUDIO_API_KEY environment variable is required")
raise ValueError("SIM_API_KEY environment variable is required")
client = SimStudioClient(
api_key=api_key,
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```

View File

@@ -230,7 +230,7 @@ class SimStudioError extends Error {
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function runWorkflow() {
@@ -271,7 +271,7 @@ Maneja diferentes tipos de errores que pueden ocurrir durante la ejecución del
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function executeWithErrorHandling() {
@@ -315,14 +315,14 @@ Configura el cliente usando variables de entorno:
import { SimStudioClient } from 'simstudio-ts-sdk';
// Development configuration
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
baseUrl: process.env.SIM_BASE_URL // optional
});
```
@@ -333,14 +333,14 @@ Configura el cliente usando variables de entorno:
import { SimStudioClient } from 'simstudio-ts-sdk';
// Production configuration with validation
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://sim.ai'
baseUrl: process.env.SIM_BASE_URL || 'https://sim.ai'
});
```
@@ -357,7 +357,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
const app = express();
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
app.use(express.json());
@@ -399,7 +399,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
export default async function handler(
@@ -476,7 +476,7 @@ import { useState, useCallback } from 'react';
import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY!
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
});
interface UseWorkflowResult {
@@ -588,7 +588,7 @@ import {
// Type-safe client initialization
const client: SimStudioClient = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
// Type-safe workflow execution

View File

@@ -214,7 +214,7 @@ class SimStudioError(Exception):
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def run_workflow():
try:
@@ -252,7 +252,7 @@ Gérez différents types d'erreurs qui peuvent survenir pendant l'exécution du
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_error_handling():
try:
@@ -284,7 +284,7 @@ from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
@@ -298,7 +298,7 @@ Exécutez plusieurs flux de travail efficacement :
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
@@ -352,8 +352,8 @@ Configurez le client en utilisant des variables d'environnement :
# Development configuration
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
@@ -365,13 +365,13 @@ Configurez le client en utilisant des variables d'environnement :
from simstudio import SimStudioClient
# Production configuration with error handling
api_key = os.getenv("SIMSTUDIO_API_KEY")
api_key = os.getenv("SIM_API_KEY")
if not api_key:
raise ValueError("SIMSTUDIO_API_KEY environment variable is required")
raise ValueError("SIM_API_KEY environment variable is required")
client = SimStudioClient(
api_key=api_key,
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```

View File

@@ -230,7 +230,7 @@ class SimStudioError extends Error {
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function runWorkflow() {
@@ -271,7 +271,7 @@ Gérez différents types d'erreurs qui peuvent survenir pendant l'exécution du
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function executeWithErrorHandling() {
@@ -315,14 +315,14 @@ Configurez le client en utilisant des variables d'environnement :
import { SimStudioClient } from 'simstudio-ts-sdk';
// Development configuration
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
baseUrl: process.env.SIM_BASE_URL // optional
});
```
@@ -333,14 +333,14 @@ Configurez le client en utilisant des variables d'environnement :
import { SimStudioClient } from 'simstudio-ts-sdk';
// Production configuration with validation
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://sim.ai'
baseUrl: process.env.SIM_BASE_URL || 'https://sim.ai'
});
```
@@ -357,7 +357,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
const app = express();
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
app.use(express.json());
@@ -399,7 +399,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
export default async function handler(
@@ -476,7 +476,7 @@ import { useState, useCallback } from 'react';
import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY!
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
});
interface UseWorkflowResult {
@@ -588,7 +588,7 @@ import {
// Type-safe client initialization
const client: SimStudioClient = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
// Type-safe workflow execution

View File

@@ -214,7 +214,7 @@ class SimStudioError(Exception):
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def run_workflow():
try:
@@ -252,7 +252,7 @@ run_workflow()
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_error_handling():
try:
@@ -284,7 +284,7 @@ from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
@@ -298,7 +298,7 @@ with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
@@ -352,8 +352,8 @@ for result in results:
# Development configuration
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
@@ -365,13 +365,13 @@ for result in results:
from simstudio import SimStudioClient
# Production configuration with error handling
api_key = os.getenv("SIMSTUDIO_API_KEY")
api_key = os.getenv("SIM_API_KEY")
if not api_key:
raise ValueError("SIMSTUDIO_API_KEY environment variable is required")
raise ValueError("SIM_API_KEY environment variable is required")
client = SimStudioClient(
api_key=api_key,
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```

View File

@@ -230,7 +230,7 @@ class SimStudioError extends Error {
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function runWorkflow() {
@@ -271,7 +271,7 @@ runWorkflow();
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function executeWithErrorHandling() {
@@ -315,14 +315,14 @@ async function executeWithErrorHandling() {
import { SimStudioClient } from 'simstudio-ts-sdk';
// Development configuration
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
baseUrl: process.env.SIM_BASE_URL // optional
});
```
@@ -333,14 +333,14 @@ async function executeWithErrorHandling() {
import { SimStudioClient } from 'simstudio-ts-sdk';
// Production configuration with validation
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://sim.ai'
baseUrl: process.env.SIM_BASE_URL || 'https://sim.ai'
});
```
@@ -357,7 +357,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
const app = express();
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
app.use(express.json());
@@ -399,7 +399,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
export default async function handler(
@@ -476,7 +476,7 @@ import { useState, useCallback } from 'react';
import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY!
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
});
interface UseWorkflowResult {
@@ -588,7 +588,7 @@ import {
// Type-safe client initialization
const client: SimStudioClient = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
// Type-safe workflow execution

View File

@@ -214,7 +214,7 @@ class SimStudioError(Exception):
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def run_workflow():
try:
@@ -252,7 +252,7 @@ run_workflow()
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_error_handling():
try:
@@ -284,7 +284,7 @@ from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
@@ -298,7 +298,7 @@ with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
@@ -352,8 +352,8 @@ for result in results:
# Development configuration
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
@@ -365,13 +365,13 @@ for result in results:
from simstudio import SimStudioClient
# Production configuration with error handling
api_key = os.getenv("SIMSTUDIO_API_KEY")
api_key = os.getenv("SIM_API_KEY")
if not api_key:
raise ValueError("SIMSTUDIO_API_KEY environment variable is required")
raise ValueError("SIM_API_KEY environment variable is required")
client = SimStudioClient(
api_key=api_key,
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```

View File

@@ -230,7 +230,7 @@ class SimStudioError extends Error {
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function runWorkflow() {
@@ -271,7 +271,7 @@ runWorkflow();
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function executeWithErrorHandling() {
@@ -315,14 +315,14 @@ async function executeWithErrorHandling() {
import { SimStudioClient } from 'simstudio-ts-sdk';
// Development configuration
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
baseUrl: process.env.SIM_BASE_URL // optional
});
```
@@ -333,14 +333,14 @@ async function executeWithErrorHandling() {
import { SimStudioClient } from 'simstudio-ts-sdk';
// Production configuration with validation
const apiKey = process.env.SIMSTUDIO_API_KEY;
const apiKey = process.env.SIM_API_KEY;
if (!apiKey) {
throw new Error('SIMSTUDIO_API_KEY environment variable is required');
throw new Error('SIM_API_KEY environment variable is required');
}
const client = new SimStudioClient({
apiKey,
baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://sim.ai'
baseUrl: process.env.SIM_BASE_URL || 'https://sim.ai'
});
```
@@ -357,7 +357,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
const app = express();
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
app.use(express.json());
@@ -399,7 +399,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
export default async function handler(
@@ -476,7 +476,7 @@ import { useState, useCallback } from 'react';
import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY!
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
});
interface UseWorkflowResult {
@@ -588,7 +588,7 @@ import {
// Type-safe client initialization
const client: SimStudioClient = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
// Type-safe workflow execution

View File

@@ -9,7 +9,7 @@ export function cn(...inputs: ClassValue[]) {
}
/**
* Get the full URL for an asset stored in Vercel Blob or local fallback
* Get the full URL for an asset stored in Vercel Blob
* - If CDN is configured (NEXT_PUBLIC_BLOB_BASE_URL), uses CDN URL
* - Otherwise falls back to local static assets served from root path
*/
@@ -20,12 +20,3 @@ export function getAssetUrl(filename: string) {
}
return `/${filename}`
}
/**
* Get the full URL for a video asset stored in Vercel Blob or local fallback
* - If CDN is configured (NEXT_PUBLIC_BLOB_BASE_URL), uses CDN URL
* - Otherwise falls back to local static assets served from root path
*/
export function getVideoUrl(filename: string) {
return getAssetUrl(filename)
}

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { getAssetUrl } from '@/lib/utils'
import { inter } from '@/app/fonts/inter'
interface Testimonial {
@@ -14,7 +13,6 @@ interface Testimonial {
profileImage: string
}
// Import all testimonials
const allTestimonials: Testimonial[] = [
{
text: "🚨 BREAKING: This startup just dropped the fastest way to build AI agents.\n\nThis Figma-like canvas to build agents will blow your mind.\n\nHere's why this is the best tool for building AI agents:",
@@ -22,7 +20,7 @@ const allTestimonials: Testimonial[] = [
username: '@hasantoxr',
viewCount: '515k',
tweetUrl: 'https://x.com/hasantoxr/status/1912909502036525271',
profileImage: getAssetUrl('twitter/hasan.jpg'),
profileImage: '/twitter/hasan.jpg',
},
{
text: "Drag-and-drop AI workflows for devs who'd rather build agents than babysit them.",
@@ -30,7 +28,7 @@ const allTestimonials: Testimonial[] = [
username: '@GithubProjects',
viewCount: '90.4k',
tweetUrl: 'https://x.com/GithubProjects/status/1906383555707490499',
profileImage: getAssetUrl('twitter/github-projects.jpg'),
profileImage: '/twitter/github-projects.jpg',
},
{
text: "🚨 BREAKING: This startup just dropped the fastest way to build AI agents.\n\nThis Figma-like canvas to build agents will blow your mind.\n\nHere's why this is the best tool for building AI agents:",
@@ -38,7 +36,7 @@ const allTestimonials: Testimonial[] = [
username: '@lazukars',
viewCount: '47.4k',
tweetUrl: 'https://x.com/lazukars/status/1913136390503600575',
profileImage: getAssetUrl('twitter/lazukars.png'),
profileImage: '/twitter/lazukars.png',
},
{
text: 'omfggggg this is the zapier of agent building\n\ni always believed that building agents and using ai should not be limited to technical people. i think this solves just that\n\nthe fact that this is also open source makes me so optimistic about the future of building with ai :)))\n\ncongrats @karabegemir & @typingwala !!!',
@@ -46,7 +44,7 @@ const allTestimonials: Testimonial[] = [
username: '@nizzyabi',
viewCount: '6,269',
tweetUrl: 'https://x.com/nizzyabi/status/1907864421227180368',
profileImage: getAssetUrl('twitter/nizzy.jpg'),
profileImage: '/twitter/nizzy.jpg',
},
{
text: 'A very good looking agent workflow builder 🔥 and open source!',
@@ -54,7 +52,7 @@ const allTestimonials: Testimonial[] = [
username: '@xyflowdev',
viewCount: '3,246',
tweetUrl: 'https://x.com/xyflowdev/status/1909501499719438670',
profileImage: getAssetUrl('twitter/xyflow.jpg'),
profileImage: '/twitter/xyflow.jpg',
},
{
text: "One of the best products I've seen in the space, and the hustle and grind I've seen from @karabegemir and @typingwala is insane. Sim is positioned to build something game-changing, and there's no better team for the job.\n\nCongrats on the launch 🚀 🎊 great things ahead!",
@@ -62,7 +60,7 @@ const allTestimonials: Testimonial[] = [
username: '@firestorm776',
viewCount: '1,256',
tweetUrl: 'https://x.com/firestorm776/status/1907896097735061598',
profileImage: getAssetUrl('twitter/samarth.jpg'),
profileImage: '/twitter/samarth.jpg',
},
{
text: 'lfgg got access to @simstudioai via @zerodotemail 😎',
@@ -70,7 +68,7 @@ const allTestimonials: Testimonial[] = [
username: '@nizzyabi',
viewCount: '1,762',
tweetUrl: 'https://x.com/nizzyabi/status/1910482357821595944',
profileImage: getAssetUrl('twitter/nizzy.jpg'),
profileImage: '/twitter/nizzy.jpg',
},
{
text: 'Feels like we\'re finally getting a "Photoshop moment" for AI devs—visual, intuitive, and fast enough to keep up with ideas mid-flow.',
@@ -78,7 +76,7 @@ const allTestimonials: Testimonial[] = [
username: '@syamrajk',
viewCount: '2,784',
tweetUrl: 'https://x.com/syamrajk/status/1912911980110946491',
profileImage: getAssetUrl('twitter/syamrajk.jpg'),
profileImage: '/twitter/syamrajk.jpg',
},
{
text: 'The use cases are endless. Great work @simstudioai',
@@ -86,7 +84,7 @@ const allTestimonials: Testimonial[] = [
username: '@daniel_zkim',
viewCount: '103',
tweetUrl: 'https://x.com/daniel_zkim/status/1907891273664782708',
profileImage: getAssetUrl('twitter/daniel.jpg'),
profileImage: '/twitter/daniel.jpg',
},
]
@@ -95,11 +93,9 @@ export default function Testimonials() {
const [isTransitioning, setIsTransitioning] = useState(false)
const [isPaused, setIsPaused] = useState(false)
// Create an extended array for smooth infinite scrolling
const extendedTestimonials = [...allTestimonials, ...allTestimonials]
useEffect(() => {
// Set up automatic sliding every 3 seconds
const interval = setInterval(() => {
if (!isPaused) {
setIsTransitioning(true)
@@ -110,17 +106,15 @@ export default function Testimonials() {
return () => clearInterval(interval)
}, [isPaused])
// Reset position when reaching the end for infinite loop
useEffect(() => {
if (currentIndex >= allTestimonials.length) {
setTimeout(() => {
setIsTransitioning(false)
setCurrentIndex(0)
}, 500) // Match transition duration
}, 500)
}
}, [currentIndex])
// Calculate the transform value
const getTransformValue = () => {
// Each card unit (card + separator) takes exactly 25% width
return `translateX(-${currentIndex * 25}%)`

View File

@@ -27,7 +27,7 @@ describe('Chat Identifier API Route', () => {
const mockAddCorsHeaders = vi.fn().mockImplementation((response) => response)
const mockValidateChatAuth = vi.fn().mockResolvedValue({ authorized: true })
const mockSetChatAuthCookie = vi.fn()
const mockExecuteWorkflowForChat = vi.fn().mockResolvedValue(createMockStream())
const mockCreateStreamingResponse = vi.fn().mockResolvedValue(createMockStream())
const mockChatResult = [
{
@@ -72,7 +72,16 @@ describe('Chat Identifier API Route', () => {
validateChatAuth: mockValidateChatAuth,
setChatAuthCookie: mockSetChatAuthCookie,
validateAuthToken: vi.fn().mockReturnValue(true),
executeWorkflowForChat: mockExecuteWorkflowForChat,
}))
vi.doMock('@/lib/workflows/streaming', () => ({
createStreamingResponse: mockCreateStreamingResponse,
SSE_HEADERS: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
}))
vi.doMock('@/lib/logs/console/logger', () => ({
@@ -369,8 +378,23 @@ describe('Chat Identifier API Route', () => {
expect(response.headers.get('Cache-Control')).toBe('no-cache')
expect(response.headers.get('Connection')).toBe('keep-alive')
// Verify executeWorkflowForChat was called with correct parameters
expect(mockExecuteWorkflowForChat).toHaveBeenCalledWith('chat-id', 'Hello world', 'conv-123')
// Verify createStreamingResponse was called with correct workflow info
expect(mockCreateStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
workflow: expect.objectContaining({
id: 'workflow-id',
userId: 'user-id',
}),
input: expect.objectContaining({
input: 'Hello world',
conversationId: 'conv-123',
}),
streamConfig: expect.objectContaining({
isSecureMode: true,
workflowTriggerType: 'chat',
}),
})
)
})
it('should handle streaming response body correctly', async () => {
@@ -399,8 +423,8 @@ describe('Chat Identifier API Route', () => {
})
it('should handle workflow execution errors gracefully', async () => {
const originalExecuteWorkflow = mockExecuteWorkflowForChat.getMockImplementation()
mockExecuteWorkflowForChat.mockImplementationOnce(async () => {
const originalStreamingResponse = mockCreateStreamingResponse.getMockImplementation()
mockCreateStreamingResponse.mockImplementationOnce(async () => {
throw new Error('Execution failed')
})
@@ -417,8 +441,8 @@ describe('Chat Identifier API Route', () => {
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Execution failed')
if (originalExecuteWorkflow) {
mockExecuteWorkflowForChat.mockImplementation(originalExecuteWorkflow)
if (originalStreamingResponse) {
mockCreateStreamingResponse.mockImplementation(originalStreamingResponse)
}
})
@@ -443,7 +467,7 @@ describe('Chat Identifier API Route', () => {
expect(data).toHaveProperty('message', 'Invalid request body')
})
it('should pass conversationId to executeWorkflowForChat when provided', async () => {
it('should pass conversationId to streaming execution when provided', async () => {
const req = createMockRequest('POST', {
input: 'Hello world',
conversationId: 'test-conversation-123',
@@ -454,10 +478,13 @@ describe('Chat Identifier API Route', () => {
await POST(req, { params })
expect(mockExecuteWorkflowForChat).toHaveBeenCalledWith(
'chat-id',
'Hello world',
'test-conversation-123'
expect(mockCreateStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
input: 'Hello world',
conversationId: 'test-conversation-123',
}),
})
)
})
@@ -469,7 +496,13 @@ describe('Chat Identifier API Route', () => {
await POST(req, { params })
expect(mockExecuteWorkflowForChat).toHaveBeenCalledWith('chat-id', 'Hello world', undefined)
expect(mockCreateStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
input: 'Hello world',
}),
})
)
})
})
})

View File

@@ -6,7 +6,6 @@ import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import {
addCorsHeaders,
executeWorkflowForChat,
setChatAuthCookie,
validateAuthToken,
validateChatAuth,
@@ -15,6 +14,9 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
const logger = createLogger('ChatIdentifierAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
// This endpoint handles chat interactions via the identifier
export async function POST(
request: NextRequest,
@@ -106,18 +108,37 @@ export async function POST(
}
try {
// Execute workflow with structured input (input + conversationId for context)
const result = await executeWorkflowForChat(deployment.id, input, conversationId)
// Transform outputConfigs to selectedOutputs format (blockId_attribute format)
const selectedOutputs: string[] = []
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
for (const config of deployment.outputConfigs) {
const outputId = config.path
? `${config.blockId}_${config.path}`
: `${config.blockId}_content`
selectedOutputs.push(outputId)
}
}
// The result is always a ReadableStream that we can pipe to the client
const streamResponse = new NextResponse(result, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
const { createStreamingResponse } = await import('@/lib/workflows/streaming')
const { SSE_HEADERS } = await import('@/lib/utils')
const { createFilteredResult } = await import('@/app/api/workflows/[id]/execute/route')
const stream = await createStreamingResponse({
requestId,
workflow: { id: deployment.workflowId, userId: deployment.userId, isDeployed: true },
input: { input, conversationId }, // Format for chat_trigger
executingUserId: deployment.userId, // Use workflow owner's ID for chat deployments
streamConfig: {
selectedOutputs,
isSecureMode: true,
workflowTriggerType: 'chat',
},
createFilteredResult,
})
const streamResponse = new NextResponse(stream, {
status: 200,
headers: SSE_HEADERS,
})
return addCorsHeaders(streamResponse, request)
} catch (error: any) {

View File

@@ -416,7 +416,7 @@ describe('Chat API Utils', () => {
execution: executionResult,
}
// Simulate the type extraction logic from executeWorkflowForChat
// Test that streaming execution wraps the result correctly
const extractedFromStreaming =
streamingResult && typeof streamingResult === 'object' && 'execution' in streamingResult
? streamingResult.execution

View File

@@ -2,28 +2,10 @@ import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { isDev } from '@/lib/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import { decryptSecret, generateRequestId } from '@/lib/utils'
import { TriggerUtils } from '@/lib/workflows/triggers'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
import { getBlock } from '@/blocks'
import { Executor } from '@/executor'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
declare global {
var __chatStreamProcessingTasks: Promise<{ success: boolean; error?: any }>[] | undefined
}
import { decryptSecret } from '@/lib/utils'
const logger = createLogger('ChatAuthUtils')
@@ -281,570 +263,3 @@ export async function validateChatAuth(
// Unknown auth type
return { authorized: false, error: 'Unsupported authentication type' }
}
/**
* Executes a workflow for a chat request and returns the formatted output.
*
* When workflows reference <start.input>, they receive the input directly.
* The conversationId is available at <start.conversationId> for maintaining chat context.
*
* @param chatId - Chat deployment identifier
* @param input - User's chat input
* @param conversationId - Optional ID for maintaining conversation context
* @returns Workflow execution result formatted for the chat interface
*/
export async function executeWorkflowForChat(
chatId: string,
input: string,
conversationId?: string
): Promise<any> {
const requestId = generateRequestId()
logger.debug(
`[${requestId}] Executing workflow for chat: ${chatId}${
conversationId ? `, conversationId: ${conversationId}` : ''
}`
)
// Find the chat deployment
const deploymentResult = await db
.select({
id: chat.id,
workflowId: chat.workflowId,
userId: chat.userId,
outputConfigs: chat.outputConfigs,
customizations: chat.customizations,
})
.from(chat)
.where(eq(chat.id, chatId))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found: ${chatId}`)
throw new Error('Chat not found')
}
const deployment = deploymentResult[0]
const workflowId = deployment.workflowId
const executionId = uuidv4()
const usageCheck = await checkServerSideUsageLimits(deployment.userId)
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${deployment.userId} has exceeded usage limits. Skipping chat execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId: deployment.workflowId,
chatId,
}
)
throw new Error(usageCheck.message || CHAT_ERROR_MESSAGES.USAGE_LIMIT_EXCEEDED)
}
// Set up logging for chat execution
const loggingSession = new LoggingSession(workflowId, executionId, 'chat', requestId)
// Check for multi-output configuration in customizations
const customizations = (deployment.customizations || {}) as Record<string, any>
let outputBlockIds: string[] = []
// Extract output configs from the new schema format
let selectedOutputIds: string[] = []
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
// Extract output IDs in the format expected by the streaming processor
logger.debug(
`[${requestId}] Found ${deployment.outputConfigs.length} output configs in deployment`
)
selectedOutputIds = deployment.outputConfigs.map((config) => {
const outputId = config.path
? `${config.blockId}_${config.path}`
: `${config.blockId}.content`
logger.debug(
`[${requestId}] Processing output config: blockId=${config.blockId}, path=${config.path || 'content'} -> outputId=${outputId}`
)
return outputId
})
// Also extract block IDs for legacy compatibility
outputBlockIds = deployment.outputConfigs.map((config) => config.blockId)
} else {
// Use customizations as fallback
outputBlockIds = Array.isArray(customizations.outputBlockIds)
? customizations.outputBlockIds
: []
}
// Fall back to customizations if we still have no outputs
if (
outputBlockIds.length === 0 &&
customizations.outputBlockIds &&
customizations.outputBlockIds.length > 0
) {
outputBlockIds = customizations.outputBlockIds
}
logger.debug(
`[${requestId}] Using ${outputBlockIds.length} output blocks and ${selectedOutputIds.length} selected output IDs for extraction`
)
// Find the workflow to check if it's deployed
const workflowResult = await db
.select({
isDeployed: workflow.isDeployed,
variables: workflow.variables,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowResult.length === 0 || !workflowResult[0].isDeployed) {
logger.warn(`[${requestId}] Workflow not found or not deployed: ${workflowId}`)
throw new Error('Workflow not available')
}
// Load the active deployed state from the deployment versions table
const { loadDeployedWorkflowState } = await import('@/lib/workflows/db-helpers')
let deployedState: WorkflowState
try {
deployedState = await loadDeployedWorkflowState(workflowId)
} catch (error) {
logger.error(`[${requestId}] Failed to load deployed state for workflow ${workflowId}:`, error)
throw new Error(`Workflow must be deployed to be available for chat`)
}
const { blocks, edges, loops, parallels } = deployedState
// Prepare for execution, similar to use-workflow-execution.ts
const mergedStates = mergeSubblockState(blocks)
const filteredStates = Object.entries(mergedStates).reduce(
(acc, [id, block]) => {
const blockConfig = getBlock(block.type)
const isTriggerBlock = blockConfig?.category === 'triggers'
const isChatTrigger = block.type === 'chat_trigger'
// Keep all non-trigger blocks and also keep the chat_trigger block
if (!isTriggerBlock || isChatTrigger) {
acc[id] = block
}
return acc
},
{} as typeof mergedStates
)
const currentBlockStates = Object.entries(filteredStates).reduce(
(acc, [id, block]) => {
acc[id] = Object.entries(block.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Get user environment variables with workspace precedence
let envVars: Record<string, string> = {}
try {
const workspaceId = workflowResult[0].workspaceId || undefined
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
deployment.userId,
workspaceId
)
envVars = { ...personalEncrypted, ...workspaceEncrypted }
} catch (error) {
logger.warn(`[${requestId}] Could not fetch environment variables:`, error)
}
let workflowVariables = {}
try {
if (workflowResult[0].variables) {
workflowVariables =
typeof workflowResult[0].variables === 'string'
? JSON.parse(workflowResult[0].variables)
: workflowResult[0].variables
}
} catch (error) {
logger.warn(`[${requestId}] Could not parse workflow variables:`, error)
}
// Filter edges to exclude connections to/from trigger blocks (same as manual execution)
const triggerBlockIds = Object.keys(mergedStates).filter((id) => {
const type = mergedStates[id].type
const blockConfig = getBlock(type)
// Exclude chat_trigger from the list so its edges are preserved
return blockConfig?.category === 'triggers' && type !== 'chat_trigger'
})
const filteredEdges = edges.filter(
(edge) => !triggerBlockIds.includes(edge.source) && !triggerBlockIds.includes(edge.target)
)
// Create serialized workflow with filtered blocks and edges
const serializedWorkflow = new Serializer().serializeWorkflow(
filteredStates,
filteredEdges,
loops,
parallels,
true // Enable validation during execution
)
// Decrypt environment variables
const decryptedEnvVars: Record<string, string> = {}
for (const [key, encryptedValue] of Object.entries(envVars)) {
try {
const { decrypted } = await decryptSecret(encryptedValue)
decryptedEnvVars[key] = decrypted
} catch (error: any) {
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}"`, error)
// Log but continue - we don't want to break execution if just one var fails
}
}
// Process block states to ensure response formats are properly parsed
const processedBlockStates = Object.entries(currentBlockStates).reduce(
(acc, [blockId, blockState]) => {
// Check if this block has a responseFormat that needs to be parsed
if (blockState.responseFormat && typeof blockState.responseFormat === 'string') {
try {
logger.debug(`[${requestId}] Parsing responseFormat for block ${blockId}`)
// Attempt to parse the responseFormat if it's a string
const parsedResponseFormat = JSON.parse(blockState.responseFormat)
acc[blockId] = {
...blockState,
responseFormat: parsedResponseFormat,
}
} catch (error) {
logger.warn(`[${requestId}] Failed to parse responseFormat for block ${blockId}`, error)
acc[blockId] = blockState
}
} else {
acc[blockId] = blockState
}
return acc
},
{} as Record<string, Record<string, any>>
)
// Start logging session
await loggingSession.safeStart({
userId: deployment.userId,
workspaceId: workflowResult[0].workspaceId || '',
variables: workflowVariables,
})
let sessionCompleted = false
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
let executionResultForLogging: ExecutionResult | null = null
try {
const streamedContent = new Map<string, string>()
const streamedBlocks = new Set<string>() // Track which blocks have started streaming
const onStream = async (streamingExecution: any): Promise<void> => {
if (!streamingExecution.stream) return
const blockId = streamingExecution.execution?.blockId
const reader = streamingExecution.stream.getReader()
if (blockId) {
streamedContent.set(blockId, '')
// Add separator if this is not the first block to stream
if (streamedBlocks.size > 0) {
// Send separator before the new block starts
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ blockId, chunk: '\n\n' })}\n\n`)
)
}
streamedBlocks.add(blockId)
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ blockId, event: 'end' })}\n\n`)
)
break
}
const chunk = new TextDecoder().decode(value)
if (blockId) {
streamedContent.set(blockId, (streamedContent.get(blockId) || '') + chunk)
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ blockId, chunk })}\n\n`))
}
} catch (error) {
logger.error('Error while reading from stream:', error)
controller.error(error)
}
}
// Determine the start block for chat execution BEFORE creating executor
const startBlock = TriggerUtils.findStartBlock(mergedStates, 'chat')
if (!startBlock) {
const errorMessage = CHAT_ERROR_MESSAGES.NO_CHAT_TRIGGER
logger.error(`[${requestId}] ${errorMessage}`)
if (!sessionCompleted) {
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: { message: errorMessage },
traceSpans: [],
})
sessionCompleted = true
}
// Send error event that the client expects
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
event: 'error',
error: CHAT_ERROR_MESSAGES.GENERIC_ERROR,
})}\n\n`
)
)
controller.close()
return
}
const startBlockId = startBlock.blockId
// Create executor AFTER confirming we have a chat trigger
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: { input: input, conversationId },
workflowVariables,
contextExtensions: {
stream: true,
selectedOutputIds: selectedOutputIds.length > 0 ? selectedOutputIds : outputBlockIds,
edges: filteredEdges.map((e: any) => ({
source: e.source,
target: e.target,
})),
onStream,
isDeployedContext: true,
},
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
let result: ExecutionResult | StreamingExecution | undefined
try {
result = await executor.execute(workflowId, startBlockId)
} catch (error: any) {
logger.error(`[${requestId}] Chat workflow execution failed:`, error)
if (!sessionCompleted) {
const executionResult = error?.executionResult || {
success: false,
output: {},
logs: [],
}
const { traceSpans } = buildTraceSpans(executionResult)
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: { message: error.message || 'Chat workflow execution failed' },
traceSpans,
})
sessionCompleted = true
}
// Send error to stream before ending
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
event: 'error',
error: error.message || 'Chat workflow execution failed',
})}\n\n`
)
)
controller.close()
return // Don't throw - just return to end the stream gracefully
}
// Handle both ExecutionResult and StreamingExecution types
const executionResult =
result && typeof result === 'object' && 'execution' in result
? (result.execution as ExecutionResult)
: (result as ExecutionResult)
executionResultForLogging = executionResult
if (executionResult?.logs) {
const processedOutputs = new Set<string>()
executionResult.logs.forEach((log: BlockLog) => {
if (streamedContent.has(log.blockId)) {
const content = streamedContent.get(log.blockId)
if (log.output && content) {
const separator = processedOutputs.size > 0 ? '\n\n' : ''
log.output.content = separator + content
processedOutputs.add(log.blockId)
}
}
})
const nonStreamingLogs = executionResult.logs.filter(
(log: BlockLog) => !streamedContent.has(log.blockId)
)
const extractBlockIdFromOutputId = (outputId: string): string => {
return outputId.includes('_') ? outputId.split('_')[0] : outputId.split('.')[0]
}
const extractPathFromOutputId = (outputId: string, blockId: string): string => {
return outputId.substring(blockId.length + 1)
}
const parseOutputContentSafely = (output: any): any => {
if (!output?.content) {
return output
}
if (typeof output.content === 'string') {
try {
return JSON.parse(output.content)
} catch (e) {
return output
}
}
return output
}
const outputsToRender = selectedOutputIds.filter((outputId) => {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
return nonStreamingLogs.some((log) => log.blockId === blockIdForOutput)
})
for (const outputId of outputsToRender) {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockIdForOutput)
const log = nonStreamingLogs.find((l) => l.blockId === blockIdForOutput)
if (log) {
let outputValue: any = log.output
if (path) {
outputValue = parseOutputContentSafely(outputValue)
const pathParts = path.split('.')
for (const part of pathParts) {
if (outputValue && typeof outputValue === 'object' && part in outputValue) {
outputValue = outputValue[part]
} else {
outputValue = undefined
break
}
}
}
if (outputValue !== undefined) {
const separator = processedOutputs.size > 0 ? '\n\n' : ''
const formattedOutput =
typeof outputValue === 'string'
? outputValue
: JSON.stringify(outputValue, null, 2)
if (!log.output.content) {
log.output.content = separator + formattedOutput
} else {
log.output.content = separator + formattedOutput
}
processedOutputs.add(log.blockId)
}
}
}
const processedCount = processStreamingBlockLogs(executionResult.logs, streamedContent)
logger.info(`Processed ${processedCount} blocks for streaming tokenization`)
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
const enrichedResult = { ...executionResult, traceSpans, totalDuration }
if (conversationId) {
if (!enrichedResult.metadata) {
enrichedResult.metadata = {
duration: totalDuration,
startTime: new Date().toISOString(),
}
}
;(enrichedResult.metadata as any).conversationId = conversationId
}
}
if (!(result && typeof result === 'object' && 'stream' in result)) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ event: 'final', data: result })}\n\n`)
)
}
if (!sessionCompleted) {
const resultForTracing =
executionResult || ({ success: true, output: {}, logs: [] } as ExecutionResult)
const { traceSpans } = buildTraceSpans(resultForTracing)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: executionResult?.metadata?.duration || 0,
finalOutput: executionResult?.output || {},
traceSpans,
})
sessionCompleted = true
}
controller.close()
} catch (error: any) {
logger.error(`[${requestId}] Chat execution streaming error:`, error)
if (!sessionCompleted && loggingSession) {
const executionResult = executionResultForLogging ||
(error?.executionResult as ExecutionResult | undefined) || {
success: false,
output: {},
logs: [],
}
const { traceSpans } = buildTraceSpans(executionResult)
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: { message: error.message || 'Stream processing error' },
traceSpans,
})
sessionCompleted = true
}
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
event: 'error',
error: error.message || 'Stream processing error',
})}\n\n`
)
)
controller.close()
}
},
})
return stream
}

View File

@@ -9,6 +9,7 @@ import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-ke
import { getSession } from '@/lib/auth'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { env } from '@/lib/env'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
@@ -34,15 +35,11 @@ const logger = createLogger('WorkflowExecuteAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
// Define the schema for environment variables
const EnvVarsSchema = z.record(z.string())
// Keep track of running executions to prevent duplicate requests
// Use a combination of workflow ID and request ID to allow concurrent executions with different inputs
const runningExecutions = new Set<string>()
// Utility function to filter out logs and workflowConnections from API response
function createFilteredResult(result: any) {
export function createFilteredResult(result: any) {
return {
...result,
logs: undefined,
@@ -55,7 +52,6 @@ function createFilteredResult(result: any) {
}
}
// Custom error class for usage limit exceeded
class UsageLimitError extends Error {
statusCode: number
constructor(message: string, statusCode = 402) {
@@ -64,20 +60,76 @@ class UsageLimitError extends Error {
}
}
async function executeWorkflow(
/**
* Resolves output IDs to the internal blockId_attribute format
* Supports both:
* - User-facing format: blockName.path (e.g., "agent1.content")
* - Internal format: blockId_attribute (e.g., "uuid_content") - used by chat deployments
*/
function resolveOutputIds(
selectedOutputs: string[] | undefined,
blocks: Record<string, any>
): string[] | undefined {
if (!selectedOutputs || selectedOutputs.length === 0) {
return selectedOutputs
}
// UUID regex to detect if it's already in blockId_attribute format
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
return selectedOutputs.map((outputId) => {
// If it starts with a UUID, it's already in blockId_attribute format (from chat deployments)
if (UUID_REGEX.test(outputId)) {
return outputId
}
// Otherwise, it's in blockName.path format from the user/API
const dotIndex = outputId.indexOf('.')
if (dotIndex === -1) {
logger.warn(`Invalid output ID format (missing dot): ${outputId}`)
return outputId
}
const blockName = outputId.substring(0, dotIndex)
const path = outputId.substring(dotIndex + 1)
// Find the block by name (case-insensitive, ignoring spaces)
const normalizedBlockName = blockName.toLowerCase().replace(/\s+/g, '')
const block = Object.values(blocks).find((b: any) => {
const normalized = (b.name || '').toLowerCase().replace(/\s+/g, '')
return normalized === normalizedBlockName
})
if (!block) {
logger.warn(`Block not found for name: ${blockName} (from output ID: ${outputId})`)
return outputId
}
const resolvedId = `${block.id}_${path}`
logger.debug(`Resolved output ID: ${outputId} -> ${resolvedId}`)
return resolvedId
})
}
export async function executeWorkflow(
workflow: any,
requestId: string,
input: any | undefined,
actorUserId: string
actorUserId: string,
streamConfig?: {
enabled: boolean
selectedOutputs?: string[]
isSecureMode?: boolean // When true, filter out all sensitive data
workflowTriggerType?: 'api' | 'chat' // Which trigger block type to look for (default: 'api')
onStream?: (streamingExec: any) => Promise<void> // Callback for streaming agent responses
onBlockComplete?: (blockId: string, output: any) => Promise<void> // Callback when any block completes
}
): Promise<any> {
const workflowId = workflow.id
const executionId = uuidv4()
// Create a unique execution key combining workflow ID and request ID
// This allows concurrent executions of the same workflow with different inputs
const executionKey = `${workflowId}:${requestId}`
// Skip if this exact execution is already running (prevents duplicate requests)
if (runningExecutions.has(executionKey)) {
logger.warn(`[${requestId}] Execution is already running: ${executionKey}`)
throw new Error('Execution is already running')
@@ -275,15 +327,20 @@ async function executeWorkflow(
true // Enable validation during execution
)
// Determine API trigger start block
// Direct API execution ONLY works with API trigger blocks (or legacy starter in api/run mode)
const startBlock = TriggerUtils.findStartBlock(mergedStates, 'api', false) // isChildWorkflow = false
// Determine trigger start block based on execution type
// - 'chat': For chat deployments (looks for chat_trigger block)
// - 'api': For direct API execution (looks for api_trigger block)
// streamConfig is passed from POST handler when using streaming/chat
const preferredTriggerType = streamConfig?.workflowTriggerType || 'api'
const startBlock = TriggerUtils.findStartBlock(mergedStates, preferredTriggerType, false)
if (!startBlock) {
logger.error(`[${requestId}] No API trigger configured for this workflow`)
throw new Error(
'No API trigger configured for this workflow. Add an API Trigger block or use a Start block in API mode.'
)
const errorMsg =
preferredTriggerType === 'api'
? 'No API trigger block found. Add an API Trigger block to this workflow.'
: 'No chat trigger block found. Add a Chat Trigger block to this workflow.'
logger.error(`[${requestId}] ${errorMsg}`)
throw new Error(errorMsg)
}
const startBlockId = startBlock.blockId
@@ -301,38 +358,50 @@ async function executeWorkflow(
}
}
// Build context extensions
const contextExtensions: any = {
executionId,
workspaceId: workflow.workspaceId,
isDeployedContext: true,
}
// Add streaming configuration if enabled
if (streamConfig?.enabled) {
contextExtensions.stream = true
contextExtensions.selectedOutputs = streamConfig.selectedOutputs || []
contextExtensions.edges = edges.map((e: any) => ({
source: e.source,
target: e.target,
}))
contextExtensions.onStream = streamConfig.onStream
contextExtensions.onBlockComplete = streamConfig.onBlockComplete
}
const executor = new Executor({
workflow: serializedWorkflow,
currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars,
workflowInput: processedInput,
workflowVariables,
contextExtensions: {
executionId,
workspaceId: workflow.workspaceId,
isDeployedContext: true,
},
contextExtensions,
})
// Set up logging on the executor
loggingSession.setupExecutor(executor)
const result = await executor.execute(workflowId, startBlockId)
// Check if we got a StreamingExecution result (with stream + execution properties)
// For API routes, we only care about the ExecutionResult part, not the stream
const executionResult = 'stream' in result && 'execution' in result ? result.execution : result
// Execute workflow (will always return ExecutionResult since we don't use onStream)
const result = (await executor.execute(workflowId, startBlockId)) as ExecutionResult
logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, {
success: executionResult.success,
executionTime: executionResult.metadata?.duration,
success: result.success,
executionTime: result.metadata?.duration,
})
// Build trace spans from execution result (works for both success and failure)
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
const { traceSpans, totalDuration } = buildTraceSpans(result)
// Update workflow run counts if execution was successful
if (executionResult.success) {
if (result.success) {
await updateWorkflowRunCounts(workflowId)
// Track API call in user stats
@@ -348,11 +417,12 @@ async function executeWorkflow(
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
finalOutput: result.output || {},
traceSpans: (traceSpans || []) as any,
})
return executionResult
// For non-streaming, return the execution result
return result
} catch (error: any) {
logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, error)
@@ -507,45 +577,76 @@ export async function POST(
const executionMode = request.headers.get('X-Execution-Mode')
const isAsync = executionMode === 'async'
// Parse request body
// Parse request body first to check for internal parameters
const body = await request.text()
logger.info(`[${requestId}] ${body ? 'Request body provided' : 'No request body provided'}`)
let input = {}
let parsedBody: any = {}
if (body) {
try {
input = JSON.parse(body)
parsedBody = JSON.parse(body)
} catch (error) {
logger.error(`[${requestId}] Failed to parse request body as JSON`, error)
return createErrorResponse('Invalid JSON in request body', 400)
}
}
logger.info(`[${requestId}] Input passed to workflow:`, input)
logger.info(`[${requestId}] Input passed to workflow:`, parsedBody)
// Get authenticated user and determine trigger type
let authenticatedUserId: string | null = null
let triggerType: TriggerType = 'manual'
const extractExecutionParams = (req: NextRequest, body: any) => {
const internalSecret = req.headers.get('X-Internal-Secret')
const isInternalCall = internalSecret === env.INTERNAL_API_SECRET
const session = await getSession()
const apiKeyHeader = request.headers.get('X-API-Key')
if (session?.user?.id && !apiKeyHeader) {
authenticatedUserId = session.user.id
triggerType = 'manual'
} else if (apiKeyHeader) {
const auth = await authenticateApiKeyFromHeader(apiKeyHeader)
if (!auth.success || !auth.userId) {
return createErrorResponse('Unauthorized', 401)
}
authenticatedUserId = auth.userId
triggerType = 'api'
if (auth.keyId) {
void updateApiKeyLastUsed(auth.keyId).catch(() => {})
return {
isSecureMode: body.isSecureMode !== undefined ? body.isSecureMode : isInternalCall,
streamResponse: req.headers.get('X-Stream-Response') === 'true' || body.stream === true,
selectedOutputs:
body.selectedOutputs ||
(req.headers.get('X-Selected-Outputs')
? JSON.parse(req.headers.get('X-Selected-Outputs')!)
: undefined),
workflowTriggerType:
body.workflowTriggerType || (isInternalCall && body.stream ? 'chat' : 'api'),
input: body.input !== undefined ? body.input : body,
}
}
if (!authenticatedUserId) {
return createErrorResponse('Authentication required', 401)
const {
isSecureMode: finalIsSecureMode,
streamResponse,
selectedOutputs,
workflowTriggerType,
input,
} = extractExecutionParams(request as NextRequest, parsedBody)
// Get authenticated user and determine trigger type
let authenticatedUserId: string
let triggerType: TriggerType = 'manual'
// For internal calls (chat deployments), use the workflow owner's ID
if (finalIsSecureMode) {
authenticatedUserId = validation.workflow.userId
triggerType = 'manual' // Chat deployments use manual trigger type (no rate limit)
} else {
const session = await getSession()
const apiKeyHeader = request.headers.get('X-API-Key')
if (session?.user?.id && !apiKeyHeader) {
authenticatedUserId = session.user.id
triggerType = 'manual'
} else if (apiKeyHeader) {
const auth = await authenticateApiKeyFromHeader(apiKeyHeader)
if (!auth.success || !auth.userId) {
return createErrorResponse('Unauthorized', 401)
}
authenticatedUserId = auth.userId
triggerType = 'api'
if (auth.keyId) {
void updateApiKeyLastUsed(auth.keyId).catch(() => {})
}
} else {
return createErrorResponse('Authentication required', 401)
}
}
// Get user subscription (checks both personal and org subscriptions)
@@ -631,13 +732,47 @@ export async function POST(
)
}
// Handle streaming response - wrap execution in SSE stream
if (streamResponse) {
// Load workflow blocks to resolve output IDs from blockName.attribute to blockId_attribute format
const deployedData = await loadDeployedWorkflowState(workflowId)
const resolvedSelectedOutputs = selectedOutputs
? resolveOutputIds(selectedOutputs, deployedData.blocks || {})
: selectedOutputs
// Use shared streaming response creator
const { createStreamingResponse } = await import('@/lib/workflows/streaming')
const { SSE_HEADERS } = await import('@/lib/utils')
const stream = await createStreamingResponse({
requestId,
workflow: validation.workflow,
input,
executingUserId: authenticatedUserId,
streamConfig: {
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: finalIsSecureMode,
workflowTriggerType,
},
createFilteredResult,
})
return new NextResponse(stream, {
status: 200,
headers: SSE_HEADERS,
})
}
// Non-streaming execution
const result = await executeWorkflow(
validation.workflow,
requestId,
input,
authenticatedUserId
authenticatedUserId,
undefined
)
// Non-streaming response
const hasResponseBlock = workflowHasResponseBlock(result)
if (hasResponseBlock) {
return createHttpResponseFromBlock(result)

View File

@@ -1,6 +1,7 @@
import type { NextRequest } from 'next/server'
import { authenticateApiKey } from '@/lib/api-key/auth'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getWorkflowById } from '@/lib/workflows/utils'
@@ -37,7 +38,11 @@ export async function validateWorkflowAccess(
}
}
// API key authentication
const internalSecret = request.headers.get('X-Internal-Secret')
if (internalSecret === env.INTERNAL_API_SECRET) {
return { workflow }
}
let apiKeyHeader = null
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() === 'x-api-key' && value) {

View File

@@ -4,8 +4,6 @@ import { useRef, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChatMessage } from '@/app/chat/components/message/message'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
// No longer need complex output extraction - backend handles this
import type { ExecutionResult } from '@/executor/types'
const logger = createLogger('UseChatStreaming')
@@ -148,11 +146,16 @@ export function useChatStreaming() {
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6)
if (data === '[DONE]') {
continue
}
try {
const json = JSON.parse(line.substring(6))
const json = JSON.parse(data)
const { blockId, chunk: contentChunk, event: eventType } = json
// Handle error events from the server
if (eventType === 'error' || json.event === 'error') {
const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR
setMessages((prev) =>
@@ -172,34 +175,11 @@ export function useChatStreaming() {
}
if (eventType === 'final' && json.data) {
// The backend has already processed and combined all outputs
// We just need to extract the combined content and use it
const result = json.data as ExecutionResult
// Collect all content from logs that have output.content (backend processed)
let combinedContent = ''
if (result.logs) {
const contentParts: string[] = []
// Get content from all logs that have processed content
result.logs.forEach((log) => {
if (log.output?.content && typeof log.output.content === 'string') {
// The backend already includes proper separators, so just collect the content
contentParts.push(log.output.content)
}
})
// Join without additional separators since backend already handles this
combinedContent = contentParts.join('')
}
// Update the existing streaming message with the final combined content
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? {
...msg,
content: combinedContent || accumulatedText, // Use combined content or fallback to streamed
isStreaming: false,
}
: msg
@@ -210,7 +190,6 @@ export function useChatStreaming() {
}
if (blockId && contentChunk) {
// Track that this block has streamed content (like chat panel)
if (!messageIdMap.has(blockId)) {
messageIdMap.set(blockId, messageId)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Eye, Maximize2, Minimize2, X } from 'lucide-react'
import { Maximize2, Minimize2, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -45,7 +45,6 @@ export function FrozenCanvasModal({
{/* Header */}
<DialogHeader className='flex flex-row items-center justify-between border-b bg-background p-4'>
<div className='flex items-center gap-3'>
<Eye className='h-5 w-5 text-blue-500 dark:text-blue-400' />
<div>
<DialogTitle className='font-semibold text-foreground text-lg'>
Logged Workflow State
@@ -83,14 +82,15 @@ export function FrozenCanvasModal({
traceSpans={traceSpans}
height='100%'
width='100%'
// Ensure preview leaves padding at edges so nodes don't touch header
/>
</div>
{/* Footer with instructions */}
<div className='border-t bg-background px-6 py-3'>
<div className='text-muted-foreground text-sm'>
💡 Click on blocks to see their input and output data at execution time. This canvas
shows the exact state of the workflow when this execution was captured.
Click on blocks to see their input and output data at execution time. This canvas shows
the exact state of the workflow when this execution was captured.
</div>
</div>
</DialogContent>

View File

@@ -582,6 +582,8 @@ export function FrozenCanvas({
workflowState={data.workflowState}
showSubBlocks={true}
isPannable={true}
defaultZoom={0.8}
fitPadding={0.25}
onNodeClick={(blockId) => {
// Always allow clicking blocks, even if they don't have execution data
// This is important for failed workflows where some blocks never executed

View File

@@ -12,16 +12,20 @@ import {
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { getEnv, isTruthy } from '@/lib/env'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select'
interface ExampleCommandProps {
command: string
apiKey: string
endpoint: string
showLabel?: boolean
getInputFormatExample?: () => string
getInputFormatExample?: (includeStreaming?: boolean) => string
workflowId: string | null
selectedStreamingOutputs: string[]
onSelectedStreamingOutputsChange: (outputs: string[]) => void
}
type ExampleMode = 'sync' | 'async'
type ExampleMode = 'sync' | 'async' | 'stream'
type ExampleType = 'execute' | 'status' | 'rate-limits'
export function ExampleCommand({
@@ -30,6 +34,9 @@ export function ExampleCommand({
endpoint,
showLabel = true,
getInputFormatExample,
workflowId,
selectedStreamingOutputs,
onSelectedStreamingOutputsChange,
}: ExampleCommandProps) {
const [mode, setMode] = useState<ExampleMode>('sync')
const [exampleType, setExampleType] = useState<ExampleType>('execute')
@@ -63,11 +70,30 @@ export function ExampleCommand({
const getDisplayCommand = () => {
const baseEndpoint = endpoint.replace(apiKey, '$SIM_API_KEY')
const inputExample = getInputFormatExample
? getInputFormatExample()
? getInputFormatExample(false) // No streaming for sync/async modes
: ' -d \'{"input": "your data here"}\''
switch (mode) {
case 'sync':
// For sync mode, use basic example without streaming
if (getInputFormatExample) {
const syncInputExample = getInputFormatExample(false)
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json"${syncInputExample} \\
${baseEndpoint}`
}
return formatCurlCommand(command, apiKey)
case 'stream':
// For stream mode, include streaming params
if (getInputFormatExample) {
const streamInputExample = getInputFormatExample(true)
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json"${streamInputExample} \\
${baseEndpoint}`
}
return formatCurlCommand(command, apiKey)
case 'async':
@@ -114,10 +140,11 @@ export function ExampleCommand({
}
return (
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
{isAsyncEnabled && (
<div className='space-y-4'>
{/* Example Command */}
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
<div className='flex items-center gap-1'>
<Button
variant='outline'
@@ -134,57 +161,84 @@ export function ExampleCommand({
<Button
variant='outline'
size='sm'
onClick={() => setMode('async')}
onClick={() => setMode('stream')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'async'
mode === 'stream'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
>
Async
Stream
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{isAsyncEnabled && (
<>
<Button
variant='outline'
size='sm'
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
disabled={mode === 'sync'}
onClick={() => setMode('async')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'async'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
>
<span className='truncate'>{getExampleTitle()}</span>
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
Async
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('execute')}
>
Async Execution
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('status')}
>
Check Job Status
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('rate-limits')}
>
Rate Limits & Usage
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
disabled={mode === 'sync' || mode === 'stream'}
>
<span className='truncate'>{getExampleTitle()}</span>
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('execute')}
>
Async Execution
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('status')}
>
Check Job Status
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('rate-limits')}
>
Rate Limits & Usage
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
{/* Output selector for Stream mode */}
{mode === 'stream' && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>Select outputs to stream</div>
<OutputSelect
workflowId={workflowId}
selectedOutputs={selectedStreamingOutputs}
onOutputSelect={onSelectedStreamingOutputsChange}
placeholder='Select outputs for streaming'
/>
</div>
)}
</div>
<div className='group relative h-[120px] rounded-md border bg-background transition-colors hover:bg-muted/50'>
<pre className='h-full overflow-auto whitespace-pre-wrap p-3 font-mono text-xs'>
{getDisplayCommand()}
</pre>
<CopyButton text={getActualCommand()} />
<div className='group relative rounded-md border bg-background transition-colors hover:bg-muted/50'>
<pre className='whitespace-pre-wrap p-3 font-mono text-xs'>{getDisplayCommand()}</pre>
<CopyButton text={getActualCommand()} />
</div>
</div>
</div>
)

View File

@@ -43,7 +43,9 @@ interface DeploymentInfoProps {
workflowId: string | null
deployedState: WorkflowState
isLoadingDeployedState: boolean
getInputFormatExample?: () => string
getInputFormatExample?: (includeStreaming?: boolean) => string
selectedStreamingOutputs: string[]
onSelectedStreamingOutputsChange: (outputs: string[]) => void
}
export function DeploymentInfo({
@@ -57,6 +59,8 @@ export function DeploymentInfo({
deployedState,
isLoadingDeployedState,
getInputFormatExample,
selectedStreamingOutputs,
onSelectedStreamingOutputsChange,
}: DeploymentInfoProps) {
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
@@ -116,6 +120,9 @@ export function DeploymentInfo({
apiKey={deploymentInfo.apiKey}
endpoint={deploymentInfo.endpoint}
getInputFormatExample={getInputFormatExample}
workflowId={workflowId}
selectedStreamingOutputs={selectedStreamingOutputs}
onSelectedStreamingOutputsChange={onSelectedStreamingOutputsChange}
/>
</div>

View File

@@ -64,7 +64,7 @@ interface DeployFormValues {
newKeyName?: string
}
type TabView = 'general' | 'api' | 'chat'
type TabView = 'general' | 'api' | 'versions' | 'chat'
export function DeployModal({
open,
@@ -92,6 +92,7 @@ export function DeployModal({
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [chatExists, setChatExists] = useState(false)
const [isChatFormValid, setIsChatFormValid] = useState(false)
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
const [versions, setVersions] = useState<WorkflowDeploymentVersionResponse[]>([])
const [versionsLoading, setVersionsLoading] = useState(false)
@@ -102,7 +103,7 @@ export function DeployModal({
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 5
const getInputFormatExample = () => {
const getInputFormatExample = (includeStreaming = false) => {
let inputFormatExample = ''
try {
const blocks = Object.values(useWorkflowStore.getState().blocks)
@@ -117,8 +118,9 @@ export function DeployModal({
if (targetBlock) {
const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat')
const exampleData: Record<string, any> = {}
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
const exampleData: Record<string, any> = {}
inputFormat.forEach((field: any) => {
if (field.name) {
switch (field.type) {
@@ -140,7 +142,40 @@ export function DeployModal({
}
}
})
}
// Add streaming parameters if enabled and outputs are selected
if (includeStreaming && selectedStreamingOutputs.length > 0) {
exampleData.stream = true
// Convert blockId_attribute format to blockName.attribute format for display
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
const convertedOutputs = selectedStreamingOutputs.map((outputId) => {
// If it starts with a UUID, convert to blockName.attribute format
if (UUID_REGEX.test(outputId)) {
const underscoreIndex = outputId.indexOf('_')
if (underscoreIndex === -1) return outputId
const blockId = outputId.substring(0, underscoreIndex)
const attribute = outputId.substring(underscoreIndex + 1)
// Find the block by ID and get its name
const block = blocks.find((b) => b.id === blockId)
if (block?.name) {
// Normalize block name: lowercase and remove spaces
const normalizedBlockName = block.name.toLowerCase().replace(/\s+/g, '')
return `${normalizedBlockName}.${attribute}`
}
}
// Already in blockName.attribute format or couldn't convert
return outputId
})
exampleData.selectedOutputs = convertedOutputs
}
if (Object.keys(exampleData).length > 0) {
inputFormatExample = ` -d '${JSON.stringify(exampleData)}'`
}
}
@@ -199,7 +234,7 @@ export function DeployModal({
setIsLoading(true)
fetchApiKeys()
fetchChatDeploymentInfo()
setActiveTab('general')
setActiveTab('api')
}
}, [open, workflowId])
@@ -231,7 +266,7 @@ export function DeployModal({
const data = await response.json()
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample()
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) // Include streaming params only if outputs selected
setDeploymentInfo({
isDeployed: data.isDeployed,
@@ -287,7 +322,7 @@ export function DeployModal({
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample()
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) // Include streaming params only if outputs selected
const newDeploymentInfo = {
isDeployed: true,
@@ -494,7 +529,7 @@ export function DeployModal({
return (
<Dialog open={open} onOpenChange={handleCloseModal}>
<DialogContent
className='flex max-h-[78vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
className='flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
@@ -510,16 +545,6 @@ export function DeployModal({
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex h-14 flex-none items-center border-b px-6'>
<div className='flex gap-2'>
<button
onClick={() => setActiveTab('general')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'general'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
General
</button>
<button
onClick={() => setActiveTab('api')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
@@ -530,6 +555,16 @@ export function DeployModal({
>
API
</button>
<button
onClick={() => setActiveTab('versions')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'versions'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Versions
</button>
<button
onClick={() => setActiveTab('chat')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
@@ -545,175 +580,6 @@ export function DeployModal({
<div className='flex-1 overflow-y-auto'>
<div className='p-6'>
{activeTab === 'general' && (
<>
{isDeployed ? (
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={
deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
isUndeploying={isUndeploying}
workflowId={workflowId}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
getInputFormatExample={getInputFormatExample}
/>
) : (
<>
{apiDeployError && (
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<div className='-mx-1 px-1'>
<DeployForm
apiKeys={apiKeys}
keysLoaded={keysLoaded}
onSubmit={onDeploy}
onApiKeyCreated={fetchApiKeys}
formId='deploy-api-form-general'
/>
</div>
</>
)}
<div className='mt-6'>
<div className='mb-3 font-medium text-sm'>Deployment Versions</div>
{versionsLoading ? (
<div className='rounded-md border p-4 text-center text-muted-foreground text-sm'>
Loading deployments...
</div>
) : versions.length === 0 ? (
<div className='rounded-md border p-4 text-center text-muted-foreground text-sm'>
No deployments yet
</div>
) : (
<>
<div className='overflow-hidden rounded-md border'>
<table className='w-full'>
<thead className='border-b bg-muted/50'>
<tr>
<th className='w-10' />
<th className='px-4 py-2 text-left font-medium text-muted-foreground text-xs'>
Version
</th>
<th className='px-4 py-2 text-left font-medium text-muted-foreground text-xs'>
Deployed By
</th>
<th className='px-4 py-2 text-left font-medium text-muted-foreground text-xs'>
Created
</th>
<th className='w-10' />
</tr>
</thead>
<tbody className='divide-y'>
{versions
.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
.map((v) => (
<tr
key={v.id}
className='cursor-pointer transition-colors hover:bg-muted/30'
onClick={() => openVersionPreview(v.version)}
>
<td className='px-4 py-2.5'>
<div
className={`h-2 w-2 rounded-full ${
v.isActive ? 'bg-green-500' : 'bg-muted-foreground/40'
}`}
title={v.isActive ? 'Active' : 'Inactive'}
/>
</td>
<td className='px-4 py-2.5'>
<span className='font-medium text-sm'>v{v.version}</span>
</td>
<td className='px-4 py-2.5'>
<span className='text-muted-foreground text-sm'>
{v.deployedBy || 'Unknown'}
</span>
</td>
<td className='px-4 py-2.5'>
<span className='text-muted-foreground text-sm'>
{new Date(v.createdAt).toLocaleDateString()}{' '}
{new Date(v.createdAt).toLocaleTimeString()}
</span>
</td>
<td
className='px-4 py-2.5'
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
disabled={activatingVersion === v.version}
>
<MoreVertical className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={() => activateVersion(v.version)}
disabled={v.isActive || activatingVersion === v.version}
>
{v.isActive
? 'Active'
: activatingVersion === v.version
? 'Activating...'
: 'Activate'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openVersionPreview(v.version)}
>
Inspect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
{versions.length > itemsPerPage && (
<div className='mt-3 flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Showing{' '}
{Math.min((currentPage - 1) * itemsPerPage + 1, versions.length)} -{' '}
{Math.min(currentPage * itemsPerPage, versions.length)} of{' '}
{versions.length}
</span>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage * itemsPerPage >= versions.length}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
</>
)}
{activeTab === 'api' && (
<>
{isDeployed ? (
@@ -730,6 +596,8 @@ export function DeployModal({
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
getInputFormatExample={getInputFormatExample}
selectedStreamingOutputs={selectedStreamingOutputs}
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
/>
) : (
<>
@@ -739,6 +607,7 @@ export function DeployModal({
<div>{apiDeployError}</div>
</div>
)}
<div className='-mx-1 px-1'>
<DeployForm
apiKeys={apiKeys}
@@ -753,6 +622,136 @@ export function DeployModal({
</>
)}
{activeTab === 'versions' && (
<>
<div className='mb-3 font-medium text-sm'>Deployment Versions</div>
{versionsLoading ? (
<div className='rounded-md border p-4 text-center text-muted-foreground text-sm'>
Loading deployments...
</div>
) : versions.length === 0 ? (
<div className='rounded-md border p-4 text-center text-muted-foreground text-sm'>
No deployments yet
</div>
) : (
<>
<div className='overflow-hidden rounded-md border'>
<table className='w-full'>
<thead className='border-b bg-muted/50'>
<tr>
<th className='w-10' />
<th className='px-4 py-2 text-left font-medium text-muted-foreground text-xs'>
Version
</th>
<th className='px-4 py-2 text-left font-medium text-muted-foreground text-xs'>
Deployed By
</th>
<th className='px-4 py-2 text-left font-medium text-muted-foreground text-xs'>
Created
</th>
<th className='w-10' />
</tr>
</thead>
<tbody className='divide-y'>
{versions
.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
.map((v) => (
<tr
key={v.id}
className='cursor-pointer transition-colors hover:bg-muted/30'
onClick={() => openVersionPreview(v.version)}
>
<td className='px-4 py-2.5'>
<div
className={`h-2 w-2 rounded-full ${
v.isActive ? 'bg-green-500' : 'bg-muted-foreground/40'
}`}
title={v.isActive ? 'Active' : 'Inactive'}
/>
</td>
<td className='px-4 py-2.5'>
<span className='font-medium text-sm'>v{v.version}</span>
</td>
<td className='px-4 py-2.5'>
<span className='text-muted-foreground text-sm'>
{v.deployedBy || 'Unknown'}
</span>
</td>
<td className='px-4 py-2.5'>
<span className='text-muted-foreground text-sm'>
{new Date(v.createdAt).toLocaleDateString()}{' '}
{new Date(v.createdAt).toLocaleTimeString()}
</span>
</td>
<td className='px-4 py-2.5' onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
disabled={activatingVersion === v.version}
>
<MoreVertical className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={() => activateVersion(v.version)}
disabled={v.isActive || activatingVersion === v.version}
>
{v.isActive
? 'Active'
: activatingVersion === v.version
? 'Activating...'
: 'Activate'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openVersionPreview(v.version)}
>
Inspect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
{versions.length > itemsPerPage && (
<div className='mt-3 flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Showing{' '}
{Math.min((currentPage - 1) * itemsPerPage + 1, versions.length)} -{' '}
{Math.min(currentPage * itemsPerPage, versions.length)} of{' '}
{versions.length}
</span>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage * itemsPerPage >= versions.length}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</>
)}
{activeTab === 'chat' && (
<ChatDeploy
workflowId={workflowId || ''}
@@ -776,36 +775,6 @@ export function DeployModal({
</div>
</div>
{activeTab === 'general' && !isDeployed && (
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
<Button variant='outline' onClick={handleCloseModal}>
Cancel
</Button>
<Button
type='submit'
form='deploy-api-form-general'
disabled={isSubmitting || (!keysLoaded && !apiKeys.length)}
className={cn(
'gap-2 font-medium',
'bg-[var(--brand-primary-hover-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hover-hex)] disabled:hover:shadow-none'
)}
>
{isSubmitting ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Deploying...
</>
) : (
'Deploy'
)}
</Button>
</div>
)}
{activeTab === 'api' && !isDeployed && (
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
<Button variant='outline' onClick={handleCloseModal}>

View File

@@ -56,7 +56,8 @@ export function DeployedWorkflowCard({
<div className='flex items-center justify-between'>
<h3 className='font-medium'>Workflow Preview</h3>
<div className='flex items-center gap-2'>
{hasCurrent && (
{/* Show Current only when no explicit version is selected */}
{hasCurrent && !hasSelected && (
<button
type='button'
className={cn(
@@ -68,6 +69,7 @@ export function DeployedWorkflowCard({
Current
</button>
)}
{/* Always show Active Deployed */}
{hasActive && (
<button
type='button'
@@ -80,6 +82,7 @@ export function DeployedWorkflowCard({
Active Deployed
</button>
)}
{/* If a specific version is selected, show its label */}
{hasSelected && (
<button
type='button'
@@ -109,7 +112,7 @@ export function DeployedWorkflowCard({
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
defaultZoom={0.8}
/>
</div>
</CardContent>

View File

@@ -106,17 +106,22 @@ export function DeployedWorkflowModal({
selectedVersionLabel={selectedVersionLabel}
/>
<div className='mt-6 flex justify-between'>
<div className='mt-1 flex justify-between'>
<div className='flex items-center gap-2'>
{onActivateVersion && (
<Button
onClick={onActivateVersion}
disabled={isSelectedVersionActive || !!isActivating}
variant={isSelectedVersionActive ? 'secondary' : 'default'}
>
{isSelectedVersionActive ? 'Active' : isActivating ? 'Activating…' : 'Activate'}
</Button>
)}
{onActivateVersion &&
(isSelectedVersionActive ? (
<div className='inline-flex items-center gap-2 rounded-md bg-emerald-500/10 px-2.5 py-1 font-medium text-emerald-600 text-xs dark:text-emerald-400'>
<span className='relative flex h-2 w-2 items-center justify-center'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-75' />
<span className='relative inline-flex h-2 w-2 rounded-full bg-emerald-500' />
</span>
Active
</div>
) : (
<Button onClick={onActivateVersion} disabled={!!isActivating}>
{isActivating ? 'Activating…' : 'Activate'}
</Button>
))}
</div>
<div className='flex items-center gap-2'>

View File

@@ -305,7 +305,18 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) {
// Check if we got a streaming response
if (result && 'stream' in result && result.stream instanceof ReadableStream) {
const messageIdMap = new Map<string, string>()
// Create a single message for all outputs (like chat client does)
const responseMessageId = crypto.randomUUID()
let accumulatedContent = ''
// Add initial streaming message
addMessage({
id: responseMessageId,
content: '',
workflowId: activeWorkflowId,
type: 'workflow',
isStreaming: true,
})
const reader = result.stream.getReader()
const decoder = new TextDecoder()
@@ -314,8 +325,8 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) {
while (true) {
const { done, value } = await reader.read()
if (done) {
// Finalize all streaming messages
messageIdMap.forEach((id) => finalizeMessageStream(id))
// Finalize the streaming message
finalizeMessageStream(responseMessageId)
break
}
@@ -324,92 +335,38 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) {
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const json = JSON.parse(line.substring(6))
const { blockId, chunk: contentChunk, event, data } = json
const data = line.substring(6)
if (event === 'final' && data) {
const result = data as ExecutionResult
if (data === '[DONE]') {
continue
}
try {
const json = JSON.parse(data)
const { blockId, chunk: contentChunk, event, data: eventData } = json
if (event === 'final' && eventData) {
const result = eventData as ExecutionResult
// If final result is a failure, surface error and stop
if ('success' in result && !result.success) {
addMessage({
content: `Error: ${result.error || 'Workflow execution failed'}`,
workflowId: activeWorkflowId,
type: 'workflow',
})
// Clear any existing message streams
for (const msgId of messageIdMap.values()) {
finalizeMessageStream(msgId)
}
messageIdMap.clear()
// Update the existing message with error
appendMessageContent(
responseMessageId,
`${accumulatedContent ? '\n\n' : ''}Error: ${result.error || 'Workflow execution failed'}`
)
finalizeMessageStream(responseMessageId)
// Stop processing
return
}
const nonStreamingLogs =
result.logs?.filter((log) => !messageIdMap.has(log.blockId)) || []
if (nonStreamingLogs.length > 0) {
const outputsToRender = selectedOutputs.filter((outputId) => {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
return nonStreamingLogs.some((log) => log.blockId === blockIdForOutput)
})
for (const outputId of outputsToRender) {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockIdForOutput)
const log = nonStreamingLogs.find((l) => l.blockId === blockIdForOutput)
if (log) {
let output = log.output
if (path) {
output = parseOutputContentSafely(output)
const pathParts = path.split('.')
let current = output
for (const part of pathParts) {
if (current && typeof current === 'object' && part in current) {
current = current[part]
} else {
current = undefined
break
}
}
output = current
}
if (output !== undefined) {
addMessage({
content: typeof output === 'string' ? output : JSON.stringify(output),
workflowId: activeWorkflowId,
type: 'workflow',
})
}
}
}
}
// Final event just marks completion, content already streamed
finalizeMessageStream(responseMessageId)
} else if (blockId && contentChunk) {
if (!messageIdMap.has(blockId)) {
const newMessageId = crypto.randomUUID()
messageIdMap.set(blockId, newMessageId)
addMessage({
id: newMessageId,
content: contentChunk,
workflowId: activeWorkflowId,
type: 'workflow',
isStreaming: true,
})
} else {
const existingMessageId = messageIdMap.get(blockId)
if (existingMessageId) {
appendMessageContent(existingMessageId, contentChunk)
}
}
} else if (blockId && event === 'end') {
const existingMessageId = messageIdMap.get(blockId)
if (existingMessageId) {
finalizeMessageStream(existingMessageId)
}
// Accumulate all content into the single message
accumulatedContent += contentChunk
appendMessageContent(responseMessageId, contentChunk)
}
} catch (e) {
logger.error('Error parsing stream data:', e)

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { createPortal } from 'react-dom'
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
import { cn } from '@/lib/utils'
import { getBlock } from '@/blocks'
@@ -24,8 +25,42 @@ export function OutputSelect({
}: OutputSelectProps) {
const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const portalRef = useRef<HTMLDivElement>(null)
const [portalStyle, setPortalStyle] = useState<{
top: number
left: number
width: number
height: number
} | null>(null)
const blocks = useWorkflowStore((state) => state.blocks)
const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore()
// Find all scrollable ancestors so the dropdown can stay pinned on scroll
const getScrollableAncestors = (el: HTMLElement | null): (HTMLElement | Window)[] => {
const ancestors: (HTMLElement | Window)[] = []
let node: HTMLElement | null = el?.parentElement || null
const isScrollable = (elem: HTMLElement) => {
const style = window.getComputedStyle(elem)
const overflowY = style.overflowY
const overflow = style.overflow
const hasScroll = elem.scrollHeight > elem.clientHeight
return (
hasScroll &&
(overflowY === 'auto' ||
overflowY === 'scroll' ||
overflow === 'auto' ||
overflow === 'scroll')
)
}
while (node && node !== document.body) {
if (isScrollable(node)) ancestors.push(node)
node = node.parentElement
}
// Always include window as a fallback
ancestors.push(window)
return ancestors
}
// Track subblock store state to ensure proper reactivity
const subBlockValues = useSubBlockStore((state) =>
@@ -295,7 +330,10 @@ export function OutputSelect({
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
const target = event.target as Node
const insideTrigger = dropdownRef.current?.contains(target)
const insidePortal = portalRef.current?.contains(target)
if (!insideTrigger && !insidePortal) {
setIsOutputDropdownOpen(false)
}
}
@@ -306,6 +344,35 @@ export function OutputSelect({
}
}, [])
// Position the portal dropdown relative to the trigger button
useEffect(() => {
const updatePosition = () => {
if (!isOutputDropdownOpen || !dropdownRef.current) return
const rect = dropdownRef.current.getBoundingClientRect()
const available = Math.max(140, window.innerHeight - rect.bottom - 12)
const height = Math.min(available, 240)
setPortalStyle({ top: rect.bottom + 4, left: rect.left, width: rect.width, height })
}
let attachedScrollTargets: (HTMLElement | Window)[] = []
if (isOutputDropdownOpen) {
updatePosition()
window.addEventListener('resize', updatePosition)
// Attach to all scrollable ancestors (including the modal's scroll container)
attachedScrollTargets = getScrollableAncestors(dropdownRef.current)
attachedScrollTargets.forEach((target) =>
target.addEventListener('scroll', updatePosition, { passive: true })
)
}
return () => {
window.removeEventListener('resize', updatePosition)
attachedScrollTargets.forEach((target) =>
target.removeEventListener('scroll', updatePosition)
)
}
}, [isOutputDropdownOpen])
// Handle output selection - toggle selection
const handleOutputSelection = (value: string) => {
let newSelectedOutputs: string[]
@@ -359,48 +426,73 @@ export function OutputSelect({
/>
</button>
{isOutputDropdownOpen && workflowOutputs.length > 0 && (
<div className='absolute left-0 z-50 mt-1 w-full overflow-hidden rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] pt-1 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'>
<div className='max-h-[230px] overflow-y-auto'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<div className='border-[#E5E5E5] border-t px-3 pt-1.5 pb-0.5 font-normal text-muted-foreground text-xs first:border-t-0 dark:border-[#414141]'>
{blockName}
</div>
<div>
{outputs.map((output) => (
<button
type='button'
key={output.id}
onClick={() => handleOutputSelection(output.id)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left font-normal text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none'
)}
>
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: getOutputColor(output.blockId, output.blockType),
}}
>
<span className='h-3 w-3 font-bold text-white text-xs'>
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className='flex-1 truncate'>{output.path}</span>
{selectedOutputs.includes(output.id) && (
<Check className='h-4 w-4 flex-shrink-0 text-muted-foreground' />
)}
</button>
))}
</div>
{isOutputDropdownOpen &&
workflowOutputs.length > 0 &&
portalStyle &&
createPortal(
<div
ref={portalRef}
style={{
position: 'fixed',
top: portalStyle.top,
left: portalStyle.left,
width: portalStyle.width,
zIndex: 2147483647,
pointerEvents: 'auto',
}}
className='mt-0'
data-rs-scroll-lock-ignore
>
<div className='overflow-hidden rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] pt-1 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'>
<div
className='overflow-y-auto overscroll-contain'
style={{ maxHeight: portalStyle.height }}
onWheel={(e) => {
// Keep wheel scroll inside the dropdown and avoid dialog/body scroll locks
e.stopPropagation()
}}
>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<div className='border-[#E5E5E5] border-t px-3 pt-1.5 pb-0.5 font-normal text-muted-foreground text-xs first:border-t-0 dark:border-[#414141]'>
{blockName}
</div>
<div>
{outputs.map((output) => (
<button
type='button'
key={output.id}
onClick={() => handleOutputSelection(output.id)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left font-normal text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none'
)}
>
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: getOutputColor(output.blockId, output.blockType),
}}
>
<span className='h-3 w-3 font-bold text-white text-xs'>
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className='flex-1 truncate'>{output.path}</span>
{selectedOutputs.includes(output.id) && (
<Check className='h-4 w-4 flex-shrink-0 text-muted-foreground' />
)}
</button>
))}
</div>
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
</div>,
document.body
)}
</div>
)
}

View File

@@ -203,8 +203,14 @@ export function useWand({
for (const line of lines) {
if (line.startsWith('data: ')) {
const lineData = line.substring(6)
if (lineData === '[DONE]') {
continue
}
try {
const data = JSON.parse(line.substring(6))
const data = JSON.parse(lineData)
if (data.error) {
throw new Error(data.error)

View File

@@ -30,9 +30,10 @@ interface ExecutorOptions {
workflowVariables?: Record<string, any>
contextExtensions?: {
stream?: boolean
selectedOutputIds?: string[]
selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }>
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
onBlockComplete?: (blockId: string, output: any) => Promise<void>
executionId?: string
workspaceId?: string
}
@@ -323,7 +324,7 @@ export function useWorkflowExecution() {
if (isChatExecution) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
const { encodeSSE } = await import('@/lib/utils')
const executionId = uuidv4()
const streamedContent = new Map<string, string>()
const streamReadingPromises: Promise<void>[] = []
@@ -410,6 +411,8 @@ export function useWorkflowExecution() {
if (!streamingExecution.stream) return
const reader = streamingExecution.stream.getReader()
const blockId = (streamingExecution.execution as any)?.blockId
let isFirstChunk = true
if (blockId) {
streamedContent.set(blockId, '')
}
@@ -423,14 +426,17 @@ export function useWorkflowExecution() {
if (blockId) {
streamedContent.set(blockId, (streamedContent.get(blockId) || '') + chunk)
}
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
blockId,
chunk,
})}\n\n`
)
)
// Add separator before first chunk if this isn't the first block
let chunkToSend = chunk
if (isFirstChunk && streamedContent.size > 1) {
chunkToSend = `\n\n${chunk}`
isFirstChunk = false
} else if (isFirstChunk) {
isFirstChunk = false
}
controller.enqueue(encodeSSE({ blockId, chunk: chunkToSend }))
}
} catch (error) {
logger.error('Error reading from stream:', error)
@@ -440,8 +446,58 @@ export function useWorkflowExecution() {
streamReadingPromises.push(promise)
}
// Handle non-streaming blocks (like Function blocks)
const onBlockComplete = async (blockId: string, output: any) => {
// Get selected outputs from chat store
const chatStore = await import('@/stores/panel/chat/store').then(
(mod) => mod.useChatStore
)
const selectedOutputs = chatStore
.getState()
.getSelectedWorkflowOutput(activeWorkflowId)
if (!selectedOutputs?.length) return
const { extractBlockIdFromOutputId, extractPathFromOutputId, traverseObjectPath } =
await import('@/lib/response-format')
// Check if this block's output is selected
const matchingOutputs = selectedOutputs.filter(
(outputId) => extractBlockIdFromOutputId(outputId) === blockId
)
if (!matchingOutputs.length) return
// Process each selected output from this block
for (const outputId of matchingOutputs) {
const path = extractPathFromOutputId(outputId, blockId)
const outputValue = traverseObjectPath(output, path)
if (outputValue !== undefined) {
const formattedOutput =
typeof outputValue === 'string'
? outputValue
: JSON.stringify(outputValue, null, 2)
// Add separator if this isn't the first output
const separator = streamedContent.size > 0 ? '\n\n' : ''
// Send the non-streaming block output as a chunk
controller.enqueue(encodeSSE({ blockId, chunk: separator + formattedOutput }))
// Track that we've sent output for this block
streamedContent.set(blockId, formattedOutput)
}
}
}
try {
const result = await executeWorkflow(workflowInput, onStream, executionId)
const result = await executeWorkflow(
workflowInput,
onStream,
executionId,
onBlockComplete
)
// Check if execution was cancelled
if (
@@ -450,11 +506,7 @@ export function useWorkflowExecution() {
!result.success &&
result.error === 'Workflow execution was cancelled'
) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ event: 'cancelled', data: result })}\n\n`
)
)
controller.enqueue(encodeSSE({ event: 'cancelled', data: result }))
return
}
@@ -489,9 +541,8 @@ export function useWorkflowExecution() {
logger.info(`Processed ${processedCount} blocks for streaming tokenization`)
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ event: 'final', data: result })}\n\n`)
)
const { encodeSSE } = await import('@/lib/utils')
controller.enqueue(encodeSSE({ event: 'final', data: result }))
persistLogs(executionId, result).catch((err) =>
logger.error('Error persisting logs:', err)
)
@@ -511,9 +562,8 @@ export function useWorkflowExecution() {
}
// Send the error as final event so downstream handlers can treat it uniformly
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ event: 'final', data: errorResult })}\n\n`)
)
const { encodeSSE } = await import('@/lib/utils')
controller.enqueue(encodeSSE({ event: 'final', data: errorResult }))
// Persist the error to logs so it shows up in the logs page
persistLogs(executionId, errorResult).catch((err) =>
@@ -589,7 +639,8 @@ export function useWorkflowExecution() {
const executeWorkflow = async (
workflowInput?: any,
onStream?: (se: StreamingExecution) => Promise<void>,
executionId?: string
executionId?: string,
onBlockComplete?: (blockId: string, output: any) => Promise<void>
): Promise<ExecutionResult | StreamingExecution> => {
// Use currentWorkflow but check if we're in diff mode
const { blocks: workflowBlocks, edges: workflowEdges } = currentWorkflow
@@ -714,11 +765,11 @@ export function useWorkflowExecution() {
)
// If this is a chat execution, get the selected outputs
let selectedOutputIds: string[] | undefined
let selectedOutputs: string[] | undefined
if (isExecutingFromChat && activeWorkflowId) {
// Get selected outputs from chat store
const chatStore = await import('@/stores/panel/chat/store').then((mod) => mod.useChatStore)
selectedOutputIds = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
}
// Helper to extract test values from inputFormat subblock
@@ -893,12 +944,13 @@ export function useWorkflowExecution() {
workflowVariables,
contextExtensions: {
stream: isExecutingFromChat,
selectedOutputIds,
selectedOutputs,
edges: workflow.connections.map((conn) => ({
source: conn.source,
target: conn.target,
})),
onStream,
onBlockComplete,
executionId,
workspaceId,
},

View File

@@ -30,7 +30,7 @@ interface ExecutorOptions {
workflowVariables?: Record<string, any>
contextExtensions?: {
stream?: boolean
selectedOutputIds?: string[]
selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }>
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
executionId?: string
@@ -181,11 +181,11 @@ export async function executeWorkflowWithLogging(
)
// If this is a chat execution, get the selected outputs
let selectedOutputIds: string[] | undefined
let selectedOutputs: string[] | undefined
if (isExecutingFromChat) {
// Get selected outputs from chat store
const chatStore = await import('@/stores/panel/chat/store').then((mod) => mod.useChatStore)
selectedOutputIds = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
}
// Create executor options
@@ -197,7 +197,7 @@ export async function executeWorkflowWithLogging(
workflowVariables,
contextExtensions: {
stream: isExecutingFromChat,
selectedOutputIds,
selectedOutputs,
edges: workflow.connections.map((conn) => ({
source: conn.source,
target: conn.target,

View File

@@ -32,6 +32,7 @@ interface WorkflowPreviewProps {
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
fitPadding?: number
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
}
@@ -54,7 +55,8 @@ export function WorkflowPreview({
width = '100%',
isPannable = false,
defaultPosition,
defaultZoom,
defaultZoom = 0.8,
fitPadding = 0.25,
onNodeClick,
}: WorkflowPreviewProps) {
// Check if the workflow state is valid
@@ -274,6 +276,7 @@ export function WorkflowPreview({
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
fitViewOptions={{ padding: fitPadding }}
panOnScroll={false}
panOnDrag={isPannable}
zoomOnScroll={false}
@@ -298,7 +301,12 @@ export function WorkflowPreview({
: undefined
}
>
<Background />
<Background
color='hsl(var(--workflow-dots))'
size={4}
gap={40}
style={{ backgroundColor: 'hsl(var(--workflow-background))' }}
/>
</ReactFlow>
</div>
</ReactFlowProvider>

View File

@@ -13,7 +13,7 @@ import {
} from '@react-email/components'
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -24,7 +24,7 @@ interface EnterpriseSubscriptionEmailProps {
createdDate?: Date
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
export const EnterpriseSubscriptionEmail = ({
userName = 'Valued User',

View File

@@ -1,7 +1,6 @@
import { Container, Img, Link, Section, Text } from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { getEnv } from '@/lib/env'
interface UnsubscribeOptions {
unsubscribeToken?: string
@@ -14,7 +13,7 @@ interface EmailFooterProps {
}
export const EmailFooter = ({
baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai',
baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai',
unsubscribe,
}: EmailFooterProps) => {
const brand = getBrandConfig()
@@ -29,13 +28,13 @@ export const EmailFooter = ({
<tr>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://x.com/simdotai' rel='noopener noreferrer'>
<Img src={getAssetUrl('static/x-icon.png')} width='24' height='24' alt='X' />
<Img src={`${baseUrl}/static/x-icon.png`} width='24' height='24' alt='X' />
</Link>
</td>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://discord.gg/Hr4UWYEcTT' rel='noopener noreferrer'>
<Img
src={getAssetUrl('static/discord-icon.png')}
src={`${baseUrl}/static/discord-icon.png`}
width='24'
height='24'
alt='Discord'
@@ -45,7 +44,7 @@ export const EmailFooter = ({
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://github.com/simstudioai/sim' rel='noopener noreferrer'>
<Img
src={getAssetUrl('static/github-icon.png')}
src={`${baseUrl}/static/github-icon.png`}
width='24'
height='24'
alt='GitHub'

View File

@@ -12,7 +12,7 @@ import {
} from '@react-email/components'
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -23,7 +23,7 @@ interface HelpConfirmationEmailProps {
submittedDate?: Date
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
const getTypeLabel = (type: string) => {
switch (type) {

View File

@@ -13,7 +13,7 @@ import {
} from '@react-email/components'
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -26,7 +26,7 @@ interface InvitationEmailProps {
updatedDate?: Date
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
const logger = createLogger('InvitationEmail')

View File

@@ -11,7 +11,7 @@ import {
Text,
} from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -22,7 +22,7 @@ interface OTPVerificationEmailProps {
chatTitle?: string
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => {
switch (type) {

View File

@@ -14,7 +14,7 @@ import {
} from '@react-email/components'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { baseStyles } from './base-styles'
interface PlanWelcomeEmailProps {
@@ -31,7 +31,7 @@ export function PlanWelcomeEmail({
createdDate = new Date(),
}: PlanWelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
const cta = loginLink || `${baseUrl}/login`
const previewText = `${brand.name}: Your ${planName} plan is active`

View File

@@ -13,7 +13,7 @@ import {
} from '@react-email/components'
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -23,7 +23,7 @@ interface ResetPasswordEmailProps {
updatedDate?: Date
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
export const ResetPasswordEmail = ({
username = '',

View File

@@ -14,7 +14,7 @@ import {
} from '@react-email/components'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { baseStyles } from './base-styles'
interface UsageThresholdEmailProps {
@@ -37,7 +37,7 @@ export function UsageThresholdEmail({
updatedDate = new Date(),
}: UsageThresholdEmailProps) {
const brand = getBrandConfig()
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`

View File

@@ -12,7 +12,7 @@ import {
Text,
} from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -25,7 +25,7 @@ interface WorkspaceInvitationEmailProps {
invitationLink?: string
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'https://sim.ai'
export const WorkspaceInvitationEmail = ({
workspaceName = 'Workspace',

View File

@@ -886,7 +886,7 @@ describe('AgentBlockHandler', () => {
}
mockContext.stream = true
mockContext.selectedOutputIds = [mockBlock.id]
mockContext.selectedOutputs = [mockBlock.id]
const result = await handler.execute(mockBlock, inputs, mockContext)
@@ -955,7 +955,7 @@ describe('AgentBlockHandler', () => {
}
mockContext.stream = true
mockContext.selectedOutputIds = [mockBlock.id]
mockContext.selectedOutputs = [mockBlock.id]
const result = await handler.execute(mockBlock, inputs, mockContext)
@@ -1012,7 +1012,7 @@ describe('AgentBlockHandler', () => {
}
mockContext.stream = true
mockContext.selectedOutputIds = [mockBlock.id]
mockContext.selectedOutputs = [mockBlock.id]
const result = await handler.execute(mockBlock, inputs, mockContext)

View File

@@ -371,7 +371,7 @@ export class AgentBlockHandler implements BlockHandler {
private getStreamingConfig(block: SerializedBlock, context: ExecutionContext): StreamingConfig {
const isBlockSelectedForOutput =
context.selectedOutputIds?.some((outputId) => {
context.selectedOutputs?.some((outputId) => {
if (outputId === block.id) return true
const firstUnderscoreIndex = outputId.indexOf('_')
return (
@@ -382,10 +382,6 @@ export class AgentBlockHandler implements BlockHandler {
const hasOutgoingConnections = context.edges?.some((edge) => edge.source === block.id) ?? false
const shouldUseStreaming = Boolean(context.stream) && isBlockSelectedForOutput
if (shouldUseStreaming) {
logger.info(`Block ${block.id} will use streaming response`)
}
return { shouldUseStreaming, isBlockSelectedForOutput, hasOutgoingConnections }
}

View File

@@ -101,7 +101,7 @@ describe('Executor', () => {
workflow,
contextExtensions: {
stream: true,
selectedOutputIds: ['block1'],
selectedOutputs: ['block1'],
edges: [{ source: 'starter', target: 'block1' }],
onStream: mockOnStream,
},
@@ -302,7 +302,7 @@ describe('Executor', () => {
workflow,
contextExtensions: {
stream: true,
selectedOutputIds: ['block1'],
selectedOutputs: ['block1'],
onStream: mockOnStream,
},
})
@@ -322,14 +322,14 @@ describe('Executor', () => {
it.concurrent('should pass context extensions to execution context', async () => {
const workflow = createMinimalWorkflow()
const mockOnStream = vi.fn()
const selectedOutputIds = ['block1', 'block2']
const selectedOutputs = ['block1', 'block2']
const edges = [{ source: 'starter', target: 'block1' }]
const executor = new Executor({
workflow,
contextExtensions: {
stream: true,
selectedOutputIds,
selectedOutputs,
edges,
onStream: mockOnStream,
},
@@ -618,7 +618,7 @@ describe('Executor', () => {
workflow,
contextExtensions: {
stream: true,
selectedOutputIds: ['block1'],
selectedOutputs: ['block1'],
onStream: mockOnStream,
},
})
@@ -639,7 +639,7 @@ describe('Executor', () => {
workflow,
contextExtensions: {
stream: true,
selectedOutputIds: ['block1'],
selectedOutputs: ['block1'],
onStream: mockOnStream,
},
})

View File

@@ -85,6 +85,53 @@ export class Executor {
private isCancelled = false
private isChildExecution = false
/**
* Updates block output with streamed content, handling both structured and unstructured responses
*/
private updateBlockOutputWithStreamedContent(
blockId: string,
fullContent: string,
blockState: any,
context: ExecutionContext
): void {
if (!blockState?.output) return
// Check if we have response format - if so, preserve structured response
let responseFormat: any
if (this.initialBlockStates?.[blockId]) {
const initialBlockState = this.initialBlockStates[blockId] as any
responseFormat = initialBlockState.responseFormat
}
if (responseFormat && fullContent) {
// For structured responses, parse the raw streaming content
try {
const parsedContent = JSON.parse(fullContent)
// Preserve metadata but spread parsed fields at root level
const structuredOutput = {
...parsedContent,
tokens: blockState.output.tokens,
toolCalls: blockState.output.toolCalls,
providerTiming: blockState.output.providerTiming,
cost: blockState.output.cost,
}
blockState.output = structuredOutput
// Also update the corresponding block log
const blockLog = context.blockLogs.find((log) => log.blockId === blockId)
if (blockLog) {
blockLog.output = structuredOutput
}
} catch (parseError) {
// If parsing fails, fall back to setting content
blockState.output.content = fullContent
}
} else {
// No response format, use standard content setting
blockState.output.content = fullContent
}
}
constructor(
private workflowParam:
| SerializedWorkflow
@@ -96,9 +143,10 @@ export class Executor {
workflowVariables?: Record<string, any>
contextExtensions?: {
stream?: boolean
selectedOutputIds?: string[]
selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }>
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
onBlockComplete?: (blockId: string, output: any) => Promise<void>
executionId?: string
workspaceId?: string
isChildExecution?: boolean
@@ -284,7 +332,7 @@ export class Executor {
const processedClientStream = streamingResponseFormatProcessor.processStream(
streamForClient,
blockId,
context.selectedOutputIds || [],
context.selectedOutputs || [],
responseFormat
)
@@ -312,83 +360,24 @@ export class Executor {
const blockId = (streamingExec.execution as any).blockId
const blockState = context.blockStates.get(blockId)
if (blockState?.output) {
// Check if we have response format - if so, preserve structured response
let responseFormat: any
if (this.initialBlockStates?.[blockId]) {
const initialBlockState = this.initialBlockStates[blockId] as any
responseFormat = initialBlockState.responseFormat
}
if (responseFormat && fullContent) {
// For structured responses, always try to parse the raw streaming content
// The streamForExecutor contains the raw JSON response, not the processed display text
try {
const parsedContent = JSON.parse(fullContent)
// Preserve metadata but spread parsed fields at root level (same as manual execution)
const structuredOutput = {
...parsedContent,
tokens: blockState.output.tokens,
toolCalls: blockState.output.toolCalls,
providerTiming: blockState.output.providerTiming,
cost: blockState.output.cost,
}
blockState.output = structuredOutput
// Also update the corresponding block log with the structured output
const blockLog = context.blockLogs.find((log) => log.blockId === blockId)
if (blockLog) {
blockLog.output = structuredOutput
}
} catch (parseError) {
// If parsing fails, fall back to setting content
blockState.output.content = fullContent
}
} else {
// No response format, use standard content setting
blockState.output.content = fullContent
}
}
this.updateBlockOutputWithStreamedContent(
blockId,
fullContent,
blockState,
context
)
} catch (readerError: any) {
logger.error('Error reading stream for executor:', readerError)
// Set partial content if available
const blockId = (streamingExec.execution as any).blockId
const blockState = context.blockStates.get(blockId)
if (blockState?.output && fullContent) {
// Check if we have response format for error handling too
let responseFormat: any
if (this.initialBlockStates?.[blockId]) {
const initialBlockState = this.initialBlockStates[blockId] as any
responseFormat = initialBlockState.responseFormat
}
if (responseFormat) {
// For structured responses, always try to parse the raw streaming content
// The streamForExecutor contains the raw JSON response, not the processed display text
try {
const parsedContent = JSON.parse(fullContent)
const structuredOutput = {
...parsedContent,
tokens: blockState.output.tokens,
toolCalls: blockState.output.toolCalls,
providerTiming: blockState.output.providerTiming,
cost: blockState.output.cost,
}
blockState.output = structuredOutput
// Also update the corresponding block log with the structured output
const blockLog = context.blockLogs.find((log) => log.blockId === blockId)
if (blockLog) {
blockLog.output = structuredOutput
}
} catch (parseError) {
// If parsing fails, fall back to setting content
blockState.output.content = fullContent
}
} else {
// No response format, use standard content setting
blockState.output.content = fullContent
}
if (fullContent) {
this.updateBlockOutputWithStreamedContent(
blockId,
fullContent,
blockState,
context
)
}
} finally {
try {
@@ -751,9 +740,10 @@ export class Executor {
workflow: this.actualWorkflow,
// Add streaming context from contextExtensions
stream: this.contextExtensions.stream || false,
selectedOutputIds: this.contextExtensions.selectedOutputIds || [],
selectedOutputs: this.contextExtensions.selectedOutputs || [],
edges: this.contextExtensions.edges || [],
onStream: this.contextExtensions.onStream,
onBlockComplete: this.contextExtensions.onBlockComplete,
}
Object.entries(this.initialBlockStates).forEach(([blockId, output]) => {
@@ -2145,6 +2135,14 @@ export class Executor {
success: true,
})
if (context.onBlockComplete && !isNonStreamTriggerBlock) {
try {
await context.onBlockComplete(blockId, output)
} catch (callbackError: any) {
logger.error('Error in onBlockComplete callback:', callbackError)
}
}
return output
} catch (error: any) {
// Remove this block from active blocks if there's an error

View File

@@ -169,11 +169,12 @@ export interface ExecutionContext {
// Streaming support and output selection
stream?: boolean // Whether to use streaming responses when available
selectedOutputIds?: string[] // IDs of blocks selected for streaming output
selectedOutputs?: string[] // IDs of blocks selected for streaming output
edges?: Array<{ source: string; target: string }> // Workflow edge connections
// New context extensions
onStream?: (streamingExecution: StreamingExecution) => Promise<string>
onBlockComplete?: (blockId: string, output: any) => Promise<void>
}
/**
@@ -295,7 +296,7 @@ export interface ResponseFormatStreamProcessor {
processStream(
originalStream: ReadableStream,
blockId: string,
selectedOutputIds: string[],
selectedOutputs: string[],
responseFormat?: any
): ReadableStream
}

View File

@@ -11,11 +11,11 @@ export class StreamingResponseFormatProcessor implements ResponseFormatStreamPro
processStream(
originalStream: ReadableStream,
blockId: string,
selectedOutputIds: string[],
selectedOutputs: string[],
responseFormat?: any
): ReadableStream {
// Check if this block has response format selected outputs
const hasResponseFormatSelection = selectedOutputIds.some((outputId) => {
const hasResponseFormatSelection = selectedOutputs.some((outputId) => {
const blockIdForOutput = outputId.includes('_')
? outputId.split('_')[0]
: outputId.split('.')[0]
@@ -28,7 +28,7 @@ export class StreamingResponseFormatProcessor implements ResponseFormatStreamPro
}
// Get the selected field names for this block
const selectedFields = selectedOutputIds
const selectedFields = selectedOutputs
.filter((outputId) => {
const blockIdForOutput = outputId.includes('_')
? outputId.split('_')[0]

View File

@@ -57,10 +57,7 @@ export async function verifyInternalToken(token: string): Promise<boolean> {
export function verifyCronAuth(request: NextRequest, context?: string): NextResponse | null {
const authHeader = request.headers.get('authorization')
const expectedAuth = `Bearer ${env.CRON_SECRET}`
const isVercelCron = request.headers.get('x-vercel-cron') === '1'
// Allow Vercel Cron requests (they include x-vercel-cron header instead of Authorization)
if (!isVercelCron && authHeader !== expectedAuth) {
if (authHeader !== expectedAuth) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
providedAuth: authHeader,

View File

@@ -1,7 +1,6 @@
import type { Metadata } from 'next'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
/**
* Generate dynamic metadata based on brand configuration
@@ -70,7 +69,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
siteName: brand.name,
images: [
{
url: brand.logoUrl || getAssetUrl('social/facebook.png'),
url: brand.logoUrl || '/social/facebook.png',
width: 1200,
height: 630,
alt: brand.name,
@@ -81,7 +80,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
card: 'summary_large_image',
title: defaultTitle,
description: summaryFull,
images: [brand.logoUrl || getAssetUrl('social/twitter.png')],
images: [brand.logoUrl || '/social/twitter.png'],
creator: '@simstudioai',
site: '@simstudioai',
},

View File

@@ -244,9 +244,6 @@ export const env = createEnv({
// Client-side Services
NEXT_PUBLIC_SOCKET_URL: z.string().url().optional(), // WebSocket server URL for real-time features
// Asset Storage
NEXT_PUBLIC_BLOB_BASE_URL: z.string().url().optional(), // Base URL for Vercel Blob storage (CDN assets)
// Billing
NEXT_PUBLIC_BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking (client-side)
@@ -294,7 +291,6 @@ export const env = createEnv({
experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_BLOB_BASE_URL: process.env.NEXT_PUBLIC_BLOB_BASE_URL,
NEXT_PUBLIC_BILLING_ENABLED: process.env.NEXT_PUBLIC_BILLING_ENABLED,
NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
NEXT_PUBLIC_GOOGLE_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_API_KEY,

View File

@@ -8,7 +8,6 @@ const logger = createLogger('Redis')
const redisUrl = env.REDIS_URL
// Global Redis client for connection pooling
// This is important for serverless environments like Vercel
let globalRedisClient: Redis | null = null
// Fallback in-memory cache for when Redis is not available
@@ -18,7 +17,6 @@ const MAX_CACHE_SIZE = 1000
/**
* Get a Redis client instance
* Uses connection pooling to avoid creating a new connection for each request
* This is critical for performance in serverless environments like Vercel
*/
export function getRedisClient(): Redis | null {
// For server-side only

View File

@@ -75,12 +75,12 @@ export function parseResponseFormatSafely(responseFormatValue: any, blockId: str
*/
export function extractFieldValues(
parsedContent: any,
selectedOutputIds: string[],
selectedOutputs: string[],
blockId: string
): Record<string, any> {
const extractedValues: Record<string, any> = {}
for (const outputId of selectedOutputIds) {
for (const outputId of selectedOutputs) {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
if (blockIdForOutput !== blockId) {
@@ -90,18 +90,7 @@ export function extractFieldValues(
const path = extractPathFromOutputId(outputId, blockIdForOutput)
if (path) {
const pathParts = path.split('.')
let current = parsedContent
for (const part of pathParts) {
if (current && typeof current === 'object' && part in current) {
current = current[part]
} else {
current = undefined
break
}
}
const current = traverseObjectPathInternal(parsedContent, path)
if (current !== undefined) {
extractedValues[path] = current
}
@@ -165,8 +154,8 @@ export function parseOutputContentSafely(output: any): any {
/**
* Check if a set of output IDs contains response format selections for a specific block
*/
export function hasResponseFormatSelection(selectedOutputIds: string[], blockId: string): boolean {
return selectedOutputIds.some((outputId) => {
export function hasResponseFormatSelection(selectedOutputs: string[], blockId: string): boolean {
return selectedOutputs.some((outputId) => {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
return blockIdForOutput === blockId && outputId.includes('_')
})
@@ -175,11 +164,46 @@ export function hasResponseFormatSelection(selectedOutputIds: string[], blockId:
/**
* Get selected field names for a specific block from output IDs
*/
export function getSelectedFieldNames(selectedOutputIds: string[], blockId: string): string[] {
return selectedOutputIds
export function getSelectedFieldNames(selectedOutputs: string[], blockId: string): string[] {
return selectedOutputs
.filter((outputId) => {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
return blockIdForOutput === blockId && outputId.includes('_')
})
.map((outputId) => extractPathFromOutputId(outputId, blockId))
}
/**
* Internal helper to traverse an object path without parsing
* @param obj The object to traverse
* @param path The dot-separated path (e.g., "result.data.value")
* @returns The value at the path, or undefined if path doesn't exist
*/
function traverseObjectPathInternal(obj: any, path: string): any {
if (!path) return obj
let current = obj
const parts = path.split('.')
for (const part of parts) {
if (current?.[part] !== undefined) {
current = current[part]
} else {
return undefined
}
}
return current
}
/**
* Traverses an object path safely, returning undefined if any part doesn't exist
* Automatically handles parsing of output content if needed
* @param obj The object to traverse (may contain unparsed content)
* @param path The dot-separated path (e.g., "result.data.value")
* @returns The value at the path, or undefined if path doesn't exist
*/
export function traverseObjectPath(obj: any, path: string): any {
const parsed = parseOutputContentSafely(obj)
return traverseObjectPathInternal(parsed, path)
}

View File

@@ -51,7 +51,6 @@ export const buildTimeCSPDirectives: CSPDirectives = {
'https://*.atlassian.com',
'https://cdn.discordapp.com',
'https://*.githubusercontent.com',
'https://*.public.blob.vercel-storage.com',
'https://*.s3.amazonaws.com',
'https://s3.amazonaws.com',
'https://github.com/*',
@@ -152,7 +151,7 @@ export function generateRuntimeCSP(): string {
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com https://*.public.blob.vercel-storage.com ${brandLogoDomain} ${brandFaviconDomain};
img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com ${brandLogoDomain} ${brandFaviconDomain};
media-src 'self' blob:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co ${dynamicDomainsStr};

View File

@@ -41,16 +41,6 @@ export function calculateStreamingCost(
const providerId = getProviderForTokenization(model)
logger.debug('Starting streaming cost calculation', {
model,
providerId,
inputLength: inputText.length,
outputLength: outputText.length,
hasSystemPrompt: !!systemPrompt,
hasContext: !!context,
hasMessages: !!messages?.length,
})
// Estimate input tokens (combine all input sources)
const inputEstimate = estimateInputTokens(systemPrompt, context, messages, providerId)

View File

@@ -5,7 +5,7 @@
import { createLogger } from '@/lib/logs/console/logger'
import { MIN_TEXT_LENGTH_FOR_ESTIMATION, TOKENIZATION_CONFIG } from '@/lib/tokenization/constants'
import type { TokenEstimate } from '@/lib/tokenization/types'
import { createTextPreview, getProviderConfig } from '@/lib/tokenization/utils'
import { getProviderConfig } from '@/lib/tokenization/utils'
const logger = createLogger('TokenizationEstimators')
@@ -25,13 +25,6 @@ export function estimateTokenCount(text: string, providerId?: string): TokenEsti
const effectiveProviderId = providerId || TOKENIZATION_CONFIG.defaults.provider
const config = getProviderConfig(effectiveProviderId)
logger.debug('Starting token estimation', {
provider: effectiveProviderId,
textLength: text.length,
preview: createTextPreview(text),
avgCharsPerToken: config.avgCharsPerToken,
})
let estimatedTokens: number
switch (effectiveProviderId) {
@@ -49,21 +42,12 @@ export function estimateTokenCount(text: string, providerId?: string): TokenEsti
estimatedTokens = estimateGenericTokens(text, config.avgCharsPerToken)
}
const result: TokenEstimate = {
return {
count: Math.max(1, Math.round(estimatedTokens)),
confidence: config.confidence,
provider: effectiveProviderId,
method: 'heuristic',
}
logger.debug('Token estimation completed', {
provider: effectiveProviderId,
textLength: text.length,
estimatedTokens: result.count,
confidence: result.confidence,
})
return result
}
/**

View File

@@ -27,20 +27,11 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string)
// Check if we already have meaningful token/cost data
if (hasRealTokenData(log.output?.tokens) && hasRealCostData(log.output?.cost)) {
logger.debug(`Block ${log.blockId} already has real token/cost data`, {
blockType: log.blockType,
tokens: log.output?.tokens,
cost: log.output?.cost,
})
return false
}
// Check if we have content to tokenize
if (!streamedContent?.trim()) {
logger.debug(`Block ${log.blockId} has no content to tokenize`, {
blockType: log.blockType,
contentLength: streamedContent?.length || 0,
})
return false
}
@@ -51,14 +42,6 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string)
// Prepare input text from log
const inputText = extractTextContent(log.input)
logger.debug(`Starting tokenization for streaming block ${log.blockId}`, {
blockType: log.blockType,
model,
inputLength: inputText.length,
outputLength: streamedContent.length,
hasInput: !!log.input,
})
// Calculate streaming cost
const result = calculateStreamingCost(
model,
@@ -136,11 +119,6 @@ export function processStreamingBlockLogs(
): number {
let processedCount = 0
logger.debug('Processing streaming block logs for tokenization', {
totalLogs: logs.length,
streamedBlocks: streamedContentMap.size,
})
for (const log of logs) {
const content = streamedContentMap.get(log.blockId)
if (content && processStreamingBlockLog(log, content)) {

View File

@@ -375,6 +375,25 @@ export function isValidName(name: string): boolean {
return /^[a-zA-Z0-9_\s]*$/.test(name)
}
export const SSE_HEADERS = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
} as const
/**
* Encodes data as a Server-Sent Events (SSE) message.
* Formats the data as a JSON string prefixed with "data:" and suffixed with two newlines,
* then encodes it as a Uint8Array for streaming.
*
* @param data - The data to encode and send via SSE
* @returns The encoded SSE message as a Uint8Array
*/
export function encodeSSE(data: any): Uint8Array {
return new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)
}
/**
* Gets a list of invalid characters in a name
*
@@ -386,19 +405,6 @@ export function getInvalidCharacters(name: string): string[] {
return invalidChars ? [...new Set(invalidChars)] : []
}
/**
* Get the full URL for an asset stored in Vercel Blob or local fallback
* - If CDN is configured (NEXT_PUBLIC_BLOB_BASE_URL), uses CDN URL
* - Otherwise falls back to local static assets served from root path
*/
export function getAssetUrl(filename: string) {
const cdnBaseUrl = env.NEXT_PUBLIC_BLOB_BASE_URL
if (cdnBaseUrl) {
return `${cdnBaseUrl}/${filename}`
}
return `/${filename}`
}
/**
* Generate a short request ID for correlation
*/

View File

@@ -0,0 +1,181 @@
import { createLogger } from '@/lib/logs/console/logger'
import { encodeSSE } from '@/lib/utils'
import type { ExecutionResult } from '@/executor/types'
const logger = createLogger('WorkflowStreaming')
export interface StreamingConfig {
selectedOutputs?: string[]
isSecureMode?: boolean
workflowTriggerType?: 'api' | 'chat'
onStream?: (streamingExec: {
stream: ReadableStream
execution?: { blockId?: string }
}) => Promise<void>
}
export interface StreamingResponseOptions {
requestId: string
workflow: { id: string; userId: string; isDeployed?: boolean }
input: any
executingUserId: string
streamConfig: StreamingConfig
createFilteredResult: (result: ExecutionResult) => any
}
export async function createStreamingResponse(
options: StreamingResponseOptions
): Promise<ReadableStream> {
const { requestId, workflow, input, executingUserId, streamConfig, createFilteredResult } =
options
const { executeWorkflow, createFilteredResult: defaultFilteredResult } = await import(
'@/app/api/workflows/[id]/execute/route'
)
const filterResultFn = createFilteredResult || defaultFilteredResult
return new ReadableStream({
async start(controller) {
try {
const streamedContent = new Map<string, string>()
const processedOutputs = new Set<string>()
const sendChunk = (blockId: string, content: string) => {
const separator = processedOutputs.size > 0 ? '\n\n' : ''
controller.enqueue(encodeSSE({ blockId, chunk: separator + content }))
processedOutputs.add(blockId)
}
const onStreamCallback = async (streamingExec: {
stream: ReadableStream
execution?: { blockId?: string }
}) => {
const blockId = streamingExec.execution?.blockId || 'unknown'
const reader = streamingExec.stream.getReader()
const decoder = new TextDecoder()
let isFirstChunk = true
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const textChunk = decoder.decode(value, { stream: true })
streamedContent.set(blockId, (streamedContent.get(blockId) || '') + textChunk)
if (isFirstChunk) {
sendChunk(blockId, textChunk)
isFirstChunk = false
} else {
controller.enqueue(encodeSSE({ blockId, chunk: textChunk }))
}
}
} catch (streamError) {
logger.error(`[${requestId}] Error reading agent stream:`, streamError)
controller.enqueue(
encodeSSE({
event: 'stream_error',
blockId,
error: streamError instanceof Error ? streamError.message : 'Stream reading error',
})
)
}
}
const onBlockCompleteCallback = async (blockId: string, output: any) => {
if (!streamConfig.selectedOutputs?.length) return
const { extractBlockIdFromOutputId, extractPathFromOutputId, traverseObjectPath } =
await import('@/lib/response-format')
const matchingOutputs = streamConfig.selectedOutputs.filter(
(outputId) => extractBlockIdFromOutputId(outputId) === blockId
)
if (!matchingOutputs.length) return
for (const outputId of matchingOutputs) {
const path = extractPathFromOutputId(outputId, blockId)
const outputValue = traverseObjectPath(output, path)
if (outputValue !== undefined) {
const formattedOutput =
typeof outputValue === 'string' ? outputValue : JSON.stringify(outputValue, null, 2)
sendChunk(blockId, formattedOutput)
}
}
}
const result = await executeWorkflow(workflow, requestId, input, executingUserId, {
enabled: true,
selectedOutputs: streamConfig.selectedOutputs,
isSecureMode: streamConfig.isSecureMode,
workflowTriggerType: streamConfig.workflowTriggerType,
onStream: onStreamCallback,
onBlockComplete: onBlockCompleteCallback,
})
if (result.logs && streamedContent.size > 0) {
result.logs = result.logs.map((log: any) => {
if (streamedContent.has(log.blockId)) {
const content = streamedContent.get(log.blockId)
if (log.output && content) {
return { ...log, output: { ...log.output, content } }
}
}
return log
})
const { processStreamingBlockLogs } = await import('@/lib/tokenization')
processStreamingBlockLogs(result.logs, streamedContent)
}
// Create a minimal result with only selected outputs
const minimalResult = {
success: result.success,
error: result.error,
output: {} as any,
}
// If there are selected outputs, only include those specific fields
if (streamConfig.selectedOutputs?.length && result.output) {
const { extractBlockIdFromOutputId, extractPathFromOutputId, traverseObjectPath } =
await import('@/lib/response-format')
for (const outputId of streamConfig.selectedOutputs) {
const blockId = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockId)
// Find the output value from the result
if (result.logs) {
const blockLog = result.logs.find((log: any) => log.blockId === blockId)
if (blockLog?.output) {
const value = traverseObjectPath(blockLog.output, path)
if (value !== undefined) {
// Store it in a structured way
if (!minimalResult.output[blockId]) {
minimalResult.output[blockId] = {}
}
minimalResult.output[blockId][path] = value
}
}
}
}
} else if (!streamConfig.selectedOutputs?.length) {
// No selected outputs means include the full output (but still filtered)
minimalResult.output = result.output
}
controller.enqueue(encodeSSE({ event: 'final', data: minimalResult }))
controller.enqueue(encodeSSE('[DONE]'))
controller.close()
} catch (error: any) {
logger.error(`[${requestId}] Stream error:`, error)
controller.enqueue(
encodeSSE({ event: 'error', error: error.message || 'Stream processing error' })
)
controller.close()
}
},
})
}

View File

@@ -1,5 +1,5 @@
import type { NextConfig } from 'next'
import { env, isTruthy } from './lib/env'
import { env, getEnv, isTruthy } from './lib/env'
import { isDev, isHosted } from './lib/environment'
import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/csp'
@@ -20,7 +20,7 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: '*.blob.core.windows.net',
},
// AWS S3 - various regions and bucket configurations
// AWS S3
{
protocol: 'https',
hostname: '*.s3.amazonaws.com',
@@ -33,23 +33,14 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
// Custom domain for file storage if configured
...(env.NEXT_PUBLIC_BLOB_BASE_URL
? [
{
protocol: 'https' as const,
hostname: new URL(env.NEXT_PUBLIC_BLOB_BASE_URL).hostname,
},
]
: []),
// Brand logo domain if configured
...(env.NEXT_PUBLIC_BRAND_LOGO_URL
...(getEnv('NEXT_PUBLIC_BRAND_LOGO_URL')
? (() => {
try {
return [
{
protocol: 'https' as const,
hostname: new URL(env.NEXT_PUBLIC_BRAND_LOGO_URL).hostname,
hostname: new URL(getEnv('NEXT_PUBLIC_BRAND_LOGO_URL')!).hostname,
},
]
} catch {
@@ -58,13 +49,13 @@ const nextConfig: NextConfig = {
})()
: []),
// Brand favicon domain if configured
...(env.NEXT_PUBLIC_BRAND_FAVICON_URL
...(getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL')
? (() => {
try {
return [
{
protocol: 'https' as const,
hostname: new URL(env.NEXT_PUBLIC_BRAND_FAVICON_URL).hostname,
hostname: new URL(getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL')!).hostname,
},
]
} catch {

View File

@@ -91,10 +91,10 @@ Both SDKs support environment variable configuration:
```bash
# Required
SIMSTUDIO_API_KEY=your-api-key-here
SIM_API_KEY=your-api-key-here
# Optional
SIMSTUDIO_BASE_URL=https://sim.ai # or your custom domain
SIM_BASE_URL=https://sim.ai # or your custom domain
```
## Error Handling
@@ -118,7 +118,7 @@ Both SDKs provide consistent error handling with these error codes:
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
try {
@@ -150,7 +150,7 @@ try {
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv('SIMSTUDIO_API_KEY'))
client = SimStudioClient(api_key=os.getenv('SIM_API_KEY'))
try:
# Check if workflow is ready
@@ -193,13 +193,13 @@ python -m build
**TypeScript:**
```bash
cd packages/ts-sdk
SIMSTUDIO_API_KEY=your-key bun run examples/basic-usage.ts
SIM_API_KEY=your-key bun run examples/basic-usage.ts
```
**Python:**
```bash
cd packages/python-sdk
SIMSTUDIO_API_KEY=your-key python examples/basic_usage.py
SIM_API_KEY=your-key python examples/basic_usage.py
```
### Testing

View File

@@ -16,7 +16,7 @@ from simstudio import SimStudioClient
# Initialize the client
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY", "your-api-key-here"),
api_key=os.getenv("SIM_API_KEY", "your-api-key-here"),
base_url="https://sim.ai" # optional, defaults to https://sim.ai
)
@@ -180,7 +180,7 @@ class SimStudioError(Exception):
import os
from simstudio import SimStudioClient
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def run_workflow():
try:
@@ -216,7 +216,7 @@ run_workflow()
from simstudio import SimStudioClient, SimStudioError
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_with_error_handling():
try:
@@ -246,7 +246,7 @@ from simstudio import SimStudioClient
import os
# Using context manager to automatically close the session
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
result = client.execute_workflow("workflow-id")
print("Result:", result)
# Session is automatically closed here
@@ -260,8 +260,8 @@ from simstudio import SimStudioClient
# Using environment variables
client = SimStudioClient(
api_key=os.getenv("SIMSTUDIO_API_KEY"),
base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://sim.ai")
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
)
```
@@ -271,7 +271,7 @@ client = SimStudioClient(
from simstudio import SimStudioClient
import os
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""

View File

@@ -9,7 +9,7 @@ from simstudio import SimStudioClient, SimStudioError
def basic_example():
"""Example 1: Basic workflow execution"""
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
# Execute a workflow without input
@@ -31,7 +31,7 @@ def basic_example():
def with_input_example():
"""Example 2: Workflow execution with input data"""
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
result = client.execute_workflow(
@@ -66,7 +66,7 @@ def with_input_example():
def status_example():
"""Example 3: Workflow validation and status checking"""
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
# Check if workflow is ready
@@ -93,7 +93,7 @@ def status_example():
def context_manager_example():
"""Example 4: Using context manager"""
with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client:
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
try:
result = client.execute_workflow("your-workflow-id")
print(f"Result: {result}")
@@ -104,7 +104,7 @@ def context_manager_example():
def batch_execution_example():
"""Example 5: Batch workflow execution"""
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
workflows = [
("workflow-1", {"type": "analysis", "data": "sample1"}),
@@ -155,13 +155,40 @@ def batch_execution_example():
return results
def streaming_example():
"""Example 6: Workflow execution with streaming"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
result = client.execute_workflow(
"your-workflow-id",
input_data={"message": "Count to five"},
stream=True,
selected_outputs=["agent1.content"], # Use blockName.attribute format
timeout=60.0
)
if result.success:
print("✅ Workflow executed successfully!")
print(f"Output: {result.output}")
if result.metadata:
print(f"Duration: {result.metadata.get('duration')} ms")
else:
print(f"❌ Workflow failed: {result.error}")
except SimStudioError as error:
print(f"SDK Error: {error} (Code: {error.code})")
except Exception as error:
print(f"Unexpected error: {error}")
def error_handling_example():
"""Example 6: Comprehensive error handling"""
client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY"))
"""Example 7: Comprehensive error handling"""
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
try:
result = client.execute_workflow("your-workflow-id")
if result.success:
print("✅ Workflow executed successfully!")
print(f"Output: {result.output}")
@@ -194,37 +221,41 @@ if __name__ == "__main__":
print("🚀 Running Sim Python SDK Examples\n")
# Check if API key is set
if not os.getenv("SIMSTUDIO_API_KEY"):
print("❌ Please set SIMSTUDIO_API_KEY environment variable")
if not os.getenv("SIM_API_KEY"):
print("❌ Please set SIM_API_KEY environment variable")
exit(1)
try:
print("1⃣ Basic Example:")
basic_example()
print("\n✅ Basic example completed\n")
print("2⃣ Input Example:")
with_input_example()
print("\n✅ Input example completed\n")
print("3⃣ Status Example:")
status_example()
print("\n✅ Status example completed\n")
print("4⃣ Context Manager Example:")
context_manager_example()
print("\n✅ Context manager example completed\n")
print("5⃣ Batch Execution Example:")
batch_execution_example()
print("\n✅ Batch execution example completed\n")
print("6Error Handling Example:")
print("6Streaming Example:")
streaming_example()
print("\n✅ Streaming example completed\n")
print("7⃣ Error Handling Example:")
error_handling_example()
print("\n✅ Error handling example completed\n")
except Exception as e:
print(f"\n💥 Example failed: {e}")
exit(1)
print("🎉 All examples completed successfully!")

View File

@@ -4,14 +4,24 @@ Sim SDK for Python
Official Python SDK for Sim, allowing you to execute workflows programmatically.
"""
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
from dataclasses import dataclass
import time
import random
import requests
__version__ = "0.1.0"
__all__ = ["SimStudioClient", "SimStudioError", "WorkflowExecutionResult", "WorkflowStatus"]
__all__ = [
"SimStudioClient",
"SimStudioError",
"WorkflowExecutionResult",
"WorkflowStatus",
"AsyncExecutionResult",
"RateLimitInfo",
"UsageLimits",
]
@dataclass
@@ -35,6 +45,42 @@ class WorkflowStatus:
needs_redeployment: bool = False
@dataclass
class AsyncExecutionResult:
"""Result of an async workflow execution."""
success: bool
task_id: str
status: str # 'queued'
created_at: str
links: Dict[str, str]
@dataclass
class RateLimitInfo:
"""Rate limit information from API response headers."""
limit: int
remaining: int
reset: int
retry_after: Optional[int] = None
@dataclass
class RateLimitStatus:
"""Rate limit status for sync/async requests."""
is_limited: bool
limit: int
remaining: int
reset_at: str
@dataclass
class UsageLimits:
"""Usage limits and quota information."""
success: bool
rate_limit: Dict[str, Any]
usage: Dict[str, Any]
class SimStudioError(Exception):
"""Exception raised for Sim API errors."""
@@ -61,36 +107,69 @@ class SimStudioClient:
'X-API-Key': self.api_key,
'Content-Type': 'application/json',
})
self._rate_limit_info: Optional[RateLimitInfo] = None
def execute_workflow(
self,
workflow_id: str,
self,
workflow_id: str,
input_data: Optional[Dict[str, Any]] = None,
timeout: float = 30.0
) -> WorkflowExecutionResult:
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None,
async_execution: Optional[bool] = None
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
"""
Execute a workflow with optional input data.
If async_execution is True, returns immediately with a task ID.
Args:
workflow_id: The ID of the workflow to execute
input_data: Input data to pass to the workflow
timeout: Timeout in seconds (default: 30.0)
stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
async_execution: Execute asynchronously (default: None)
Returns:
WorkflowExecutionResult object containing the execution result
WorkflowExecutionResult or AsyncExecutionResult object
Raises:
SimStudioError: If the workflow execution fails
"""
url = f"{self.base_url}/api/workflows/{workflow_id}/execute"
# Build request body - spread input at root level, then add API control parameters
body = input_data.copy() if input_data is not None else {}
if stream is not None:
body['stream'] = stream
if selected_outputs is not None:
body['selectedOutputs'] = selected_outputs
# Build headers - async execution uses X-Execution-Mode header
headers = self._session.headers.copy()
if async_execution:
headers['X-Execution-Mode'] = 'async'
try:
response = self._session.post(
url,
json=input_data or {},
json=body,
headers=headers,
timeout=timeout
)
# Update rate limit info
self._update_rate_limit_info(response)
# Handle rate limiting
if response.status_code == 429:
retry_after = self._rate_limit_info.retry_after if self._rate_limit_info else 1000
raise SimStudioError(
f'Rate limit exceeded. Retry after {retry_after}ms',
'RATE_LIMIT_EXCEEDED',
429
)
if not response.ok:
try:
error_data = response.json()
@@ -99,11 +178,21 @@ class SimStudioClient:
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
result_data = response.json()
# Check if this is an async execution response (202 status)
if response.status_code == 202 and 'taskId' in result_data:
return AsyncExecutionResult(
success=result_data.get('success', True),
task_id=result_data['taskId'],
status=result_data.get('status', 'queued'),
created_at=result_data.get('createdAt', ''),
links=result_data.get('links', {})
)
return WorkflowExecutionResult(
success=result_data['success'],
output=result_data.get('output'),
@@ -113,7 +202,7 @@ class SimStudioClient:
trace_spans=result_data.get('traceSpans'),
total_duration=result_data.get('totalDuration')
)
except requests.Timeout:
raise SimStudioError(f'Workflow execution timed out after {timeout} seconds', 'TIMEOUT')
except requests.RequestException as e:
@@ -180,28 +269,32 @@ class SimStudioClient:
self,
workflow_id: str,
input_data: Optional[Dict[str, Any]] = None,
timeout: float = 30.0
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None
) -> WorkflowExecutionResult:
"""
Execute a workflow and poll for completion (useful for long-running workflows).
Note: Currently, the API is synchronous, so this method just calls execute_workflow.
In the future, if async execution is added, this method can be enhanced.
Args:
workflow_id: The ID of the workflow to execute
input_data: Input data to pass to the workflow
timeout: Timeout for the initial request in seconds
stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
Returns:
WorkflowExecutionResult object containing the execution result
Raises:
SimStudioError: If the workflow execution fails
"""
# For now, the API is synchronous, so we just execute directly
# In the future, if async execution is added, this method can be enhanced
return self.execute_workflow(workflow_id, input_data, timeout)
return self.execute_workflow(workflow_id, input_data, timeout, stream, selected_outputs)
def set_api_key(self, api_key: str) -> None:
"""
@@ -225,11 +318,189 @@ class SimStudioClient:
def close(self) -> None:
"""Close the underlying HTTP session."""
self._session.close()
def get_job_status(self, task_id: str) -> Dict[str, Any]:
"""
Get the status of an async job.
Args:
task_id: The task ID returned from async execution
Returns:
Dictionary containing the job status
Raises:
SimStudioError: If getting the status fails
"""
url = f"{self.base_url}/api/jobs/{task_id}"
try:
response = self._session.get(url)
self._update_rate_limit_info(response)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
error_code = error_data.get('code')
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
return response.json()
except requests.RequestException as e:
raise SimStudioError(f'Failed to get job status: {str(e)}', 'STATUS_ERROR')
def execute_with_retry(
self,
workflow_id: str,
input_data: Optional[Dict[str, Any]] = None,
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None,
async_execution: Optional[bool] = None,
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_multiplier: float = 2.0
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
"""
Execute workflow with automatic retry on rate limit.
Args:
workflow_id: The ID of the workflow to execute
input_data: Input data to pass to the workflow
timeout: Timeout in seconds
stream: Enable streaming responses
selected_outputs: Block outputs to stream
async_execution: Execute asynchronously
max_retries: Maximum number of retries (default: 3)
initial_delay: Initial delay in seconds (default: 1.0)
max_delay: Maximum delay in seconds (default: 30.0)
backoff_multiplier: Backoff multiplier (default: 2.0)
Returns:
WorkflowExecutionResult or AsyncExecutionResult object
Raises:
SimStudioError: If max retries exceeded or other error occurs
"""
last_error = None
delay = initial_delay
for attempt in range(max_retries + 1):
try:
return self.execute_workflow(
workflow_id,
input_data,
timeout,
stream,
selected_outputs,
async_execution
)
except SimStudioError as e:
if e.code != 'RATE_LIMIT_EXCEEDED':
raise
last_error = e
# Don't retry after last attempt
if attempt == max_retries:
break
# Use retry-after if provided, otherwise use exponential backoff
wait_time = (
self._rate_limit_info.retry_after / 1000
if self._rate_limit_info and self._rate_limit_info.retry_after
else min(delay, max_delay)
)
# Add jitter (±25%)
jitter = wait_time * (0.75 + random.random() * 0.5)
time.sleep(jitter)
# Exponential backoff for next attempt
delay *= backoff_multiplier
raise last_error or SimStudioError('Max retries exceeded', 'MAX_RETRIES_EXCEEDED')
def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
"""
Get current rate limit information.
Returns:
RateLimitInfo object or None if no rate limit info available
"""
return self._rate_limit_info
def _update_rate_limit_info(self, response: requests.Response) -> None:
"""
Update rate limit info from response headers.
Args:
response: The response object to extract headers from
"""
limit = response.headers.get('x-ratelimit-limit')
remaining = response.headers.get('x-ratelimit-remaining')
reset = response.headers.get('x-ratelimit-reset')
retry_after = response.headers.get('retry-after')
if limit or remaining or reset:
self._rate_limit_info = RateLimitInfo(
limit=int(limit) if limit else 0,
remaining=int(remaining) if remaining else 0,
reset=int(reset) if reset else 0,
retry_after=int(retry_after) * 1000 if retry_after else None
)
def get_usage_limits(self) -> UsageLimits:
"""
Get current usage limits and quota information.
Returns:
UsageLimits object containing usage and quota data
Raises:
SimStudioError: If getting usage limits fails
"""
url = f"{self.base_url}/api/users/me/usage-limits"
try:
response = self._session.get(url)
self._update_rate_limit_info(response)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
error_code = error_data.get('code')
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
data = response.json()
return UsageLimits(
success=data.get('success', True),
rate_limit=data.get('rateLimit', {}),
usage=data.get('usage', {})
)
except requests.RequestException as e:
raise SimStudioError(f'Failed to get usage limits: {str(e)}', 'USAGE_ERROR')
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()

View File

@@ -94,4 +94,371 @@ def test_context_manager(mock_close):
with SimStudioClient(api_key="test-api-key") as client:
assert client.api_key == "test-api-key"
# Should close without error
mock_close.assert_called_once()
mock_close.assert_called_once()
# Tests for async execution
@patch('simstudio.requests.Session.post')
def test_async_execution_returns_task_id(mock_post):
"""Test async execution returns AsyncExecutionResult."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 202
mock_response.json.return_value = {
"success": True,
"taskId": "task-123",
"status": "queued",
"createdAt": "2024-01-01T00:00:00Z",
"links": {"status": "/api/jobs/task-123"}
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow(
"workflow-id",
input_data={"message": "Hello"},
async_execution=True
)
assert result.success is True
assert result.task_id == "task-123"
assert result.status == "queued"
assert result.links["status"] == "/api/jobs/task-123"
# Verify X-Execution-Mode header was set
call_args = mock_post.call_args
assert call_args[1]["headers"]["X-Execution-Mode"] == "async"
@patch('simstudio.requests.Session.post')
def test_sync_execution_returns_result(mock_post):
"""Test sync execution returns WorkflowExecutionResult."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {
"success": True,
"output": {"result": "completed"},
"logs": []
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow(
"workflow-id",
input_data={"message": "Hello"},
async_execution=False
)
assert result.success is True
assert result.output == {"result": "completed"}
assert not hasattr(result, 'task_id')
@patch('simstudio.requests.Session.post')
def test_async_header_not_set_when_false(mock_post):
"""Test X-Execution-Mode header is not set when async_execution is None."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", input_data={"message": "Hello"})
call_args = mock_post.call_args
assert "X-Execution-Mode" not in call_args[1]["headers"]
# Tests for job status
@patch('simstudio.requests.Session.get')
def test_get_job_status_success(mock_get):
"""Test getting job status."""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {
"success": True,
"taskId": "task-123",
"status": "completed",
"metadata": {
"startedAt": "2024-01-01T00:00:00Z",
"completedAt": "2024-01-01T00:01:00Z",
"duration": 60000
},
"output": {"result": "done"}
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
result = client.get_job_status("task-123")
assert result["taskId"] == "task-123"
assert result["status"] == "completed"
assert result["output"]["result"] == "done"
mock_get.assert_called_once_with("https://test.sim.ai/api/jobs/task-123")
@patch('simstudio.requests.Session.get')
def test_get_job_status_not_found(mock_get):
"""Test job not found error."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 404
mock_response.reason = "Not Found"
mock_response.json.return_value = {
"error": "Job not found",
"code": "JOB_NOT_FOUND"
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info:
client.get_job_status("invalid-task")
assert "Job not found" in str(exc_info.value)
# Tests for retry with rate limiting
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
"""Test retry succeeds on first attempt."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {
"success": True,
"output": {"result": "success"}
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
result = client.execute_with_retry("workflow-id", input_data={"message": "test"})
assert result.success is True
assert mock_post.call_count == 1
assert mock_sleep.call_count == 0
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post):
"""Test retry retries on rate limit error."""
rate_limit_response = Mock()
rate_limit_response.ok = False
rate_limit_response.status_code = 429
rate_limit_response.json.return_value = {
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED"
}
import time
rate_limit_response.headers.get.side_effect = lambda h: {
'retry-after': '1',
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset': str(int(time.time()) + 60)
}.get(h)
success_response = Mock()
success_response.ok = True
success_response.status_code = 200
success_response.json.return_value = {
"success": True,
"output": {"result": "success"}
}
success_response.headers.get.return_value = None
mock_post.side_effect = [rate_limit_response, success_response]
client = SimStudioClient(api_key="test-api-key")
result = client.execute_with_retry(
"workflow-id",
input_data={"message": "test"},
max_retries=3,
initial_delay=0.01
)
assert result.success is True
assert mock_post.call_count == 2
assert mock_sleep.call_count == 1
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_max_retries_exceeded(mock_sleep, mock_post):
"""Test retry throws after max retries."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 429
mock_response.json.return_value = {
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED"
}
mock_response.headers.get.side_effect = lambda h: '1' if h == 'retry-after' else None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info:
client.execute_with_retry(
"workflow-id",
input_data={"message": "test"},
max_retries=2,
initial_delay=0.01
)
assert "Rate limit exceeded" in str(exc_info.value)
assert mock_post.call_count == 3 # Initial + 2 retries
@patch('simstudio.requests.Session.post')
def test_execute_with_retry_no_retry_on_other_errors(mock_post):
"""Test retry does not retry on non-rate-limit errors."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 500
mock_response.reason = "Internal Server Error"
mock_response.json.return_value = {
"error": "Server error",
"code": "INTERNAL_ERROR"
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info:
client.execute_with_retry("workflow-id", input_data={"message": "test"})
assert "Server error" in str(exc_info.value)
assert mock_post.call_count == 1 # No retries
# Tests for rate limit info
def test_get_rate_limit_info_returns_none_initially():
"""Test rate limit info is None before any API calls."""
client = SimStudioClient(api_key="test-api-key")
info = client.get_rate_limit_info()
assert info is None
@patch('simstudio.requests.Session.post')
def test_get_rate_limit_info_after_api_call(mock_post):
"""Test rate limit info is populated after API call."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.side_effect = lambda h: {
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '95',
'x-ratelimit-reset': '1704067200'
}.get(h)
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow("workflow-id", input_data={})
info = client.get_rate_limit_info()
assert info is not None
assert info.limit == 100
assert info.remaining == 95
assert info.reset == 1704067200
# Tests for usage limits
@patch('simstudio.requests.Session.get')
def test_get_usage_limits_success(mock_get):
"""Test getting usage limits."""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {
"success": True,
"rateLimit": {
"sync": {
"isLimited": False,
"limit": 100,
"remaining": 95,
"resetAt": "2024-01-01T01:00:00Z"
},
"async": {
"isLimited": False,
"limit": 50,
"remaining": 48,
"resetAt": "2024-01-01T01:00:00Z"
},
"authType": "api"
},
"usage": {
"currentPeriodCost": 1.23,
"limit": 100.0,
"plan": "pro"
}
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
result = client.get_usage_limits()
assert result.success is True
assert result.rate_limit["sync"]["limit"] == 100
assert result.rate_limit["async"]["limit"] == 50
assert result.usage["currentPeriodCost"] == 1.23
assert result.usage["plan"] == "pro"
mock_get.assert_called_once_with("https://test.sim.ai/api/users/me/usage-limits")
@patch('simstudio.requests.Session.get')
def test_get_usage_limits_unauthorized(mock_get):
"""Test usage limits with invalid API key."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 401
mock_response.reason = "Unauthorized"
mock_response.json.return_value = {
"error": "Invalid API key",
"code": "UNAUTHORIZED"
}
mock_response.headers.get.return_value = None
mock_get.return_value = mock_response
client = SimStudioClient(api_key="invalid-key")
with pytest.raises(SimStudioError) as exc_info:
client.get_usage_limits()
assert "Invalid API key" in str(exc_info.value)
# Tests for streaming with selectedOutputs
@patch('simstudio.requests.Session.post')
def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
"""Test execution with stream and selectedOutputs parameters."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"success": True, "output": {}}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow(
"workflow-id",
input_data={"message": "test"},
stream=True,
selected_outputs=["agent1.content", "agent2.content"]
)
call_args = mock_post.call_args
request_body = call_args[1]["json"]
assert request_body["message"] == "test"
assert request_body["stream"] is True
assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]

View File

@@ -179,7 +179,7 @@ class SimStudioError extends Error {
import { SimStudioClient } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function runWorkflow() {
@@ -218,7 +218,7 @@ runWorkflow();
import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk';
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!
apiKey: process.env.SIM_API_KEY!
});
async function executeWithErrorHandling() {
@@ -256,8 +256,8 @@ async function executeWithErrorHandling() {
```typescript
// Using environment variables
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!,
baseUrl: process.env.SIMSTUDIO_BASE_URL // optional
apiKey: process.env.SIM_API_KEY!,
baseUrl: process.env.SIM_BASE_URL // optional
});
```

View File

@@ -3,7 +3,7 @@ import { SimStudioClient, SimStudioError } from '../src/index'
// Example 1: Basic workflow execution
async function basicExample() {
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!,
apiKey: process.env.SIM_API_KEY!,
baseUrl: 'https://sim.ai',
})
@@ -30,7 +30,7 @@ async function basicExample() {
// Example 2: Workflow execution with input data
async function withInputExample() {
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!,
apiKey: process.env.SIM_API_KEY!,
})
try {
@@ -70,7 +70,7 @@ async function withInputExample() {
// Example 3: Workflow validation and status checking
async function statusExample() {
const client = new SimStudioClient({
apiKey: process.env.SIMSTUDIO_API_KEY!,
apiKey: process.env.SIM_API_KEY!,
})
try {
@@ -107,6 +107,38 @@ async function statusExample() {
}
}
// Example 4: Workflow execution with streaming
async function streamingExample() {
const client = new SimStudioClient({
apiKey: process.env.SIM_API_KEY!,
})
try {
const result = await client.executeWorkflow('your-workflow-id', {
input: {
message: 'Count to five',
},
stream: true,
selectedOutputs: ['agent1.content'], // Use blockName.attribute format
timeout: 60000,
})
if (result.success) {
console.log('✅ Workflow executed successfully!')
console.log('Output:', result.output)
console.log('Duration:', result.metadata?.duration, 'ms')
} else {
console.log('❌ Workflow failed:', result.error)
}
} catch (error) {
if (error instanceof SimStudioError) {
console.error('SDK Error:', error.message, 'Code:', error.code)
} else {
console.error('Unexpected error:', error)
}
}
}
// Run examples
if (require.main === module) {
async function runExamples() {
@@ -121,6 +153,9 @@ if (require.main === module) {
await statusExample()
console.log('\n✅ Status example completed')
await streamingExample()
console.log('\n✅ Streaming example completed')
} catch (error) {
console.error('Error running examples:', error)
}

View File

@@ -99,6 +99,414 @@ describe('SimStudioClient', () => {
expect(result).toBe(false)
})
})
describe('executeWorkflow - async execution', () => {
it('should return AsyncExecutionResult when async is true', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
status: 202,
json: vi.fn().mockResolvedValue({
success: true,
taskId: 'task-123',
status: 'queued',
createdAt: '2024-01-01T00:00:00Z',
links: {
status: '/api/jobs/task-123',
},
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.executeWorkflow('workflow-id', {
input: { message: 'Hello' },
async: true,
})
expect(result).toHaveProperty('taskId', 'task-123')
expect(result).toHaveProperty('status', 'queued')
expect(result).toHaveProperty('links')
expect((result as any).links.status).toBe('/api/jobs/task-123')
// Verify headers were set correctly
const calls = vi.mocked(fetch.default).mock.calls
expect(calls[0][1]?.headers).toMatchObject({
'X-Execution-Mode': 'async',
})
})
it('should return WorkflowExecutionResult when async is false', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
success: true,
output: { result: 'completed' },
logs: [],
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.executeWorkflow('workflow-id', {
input: { message: 'Hello' },
async: false,
})
expect(result).toHaveProperty('success', true)
expect(result).toHaveProperty('output')
expect(result).not.toHaveProperty('taskId')
})
it('should not set X-Execution-Mode header when async is undefined', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await client.executeWorkflow('workflow-id', {
input: { message: 'Hello' },
})
const calls = vi.mocked(fetch.default).mock.calls
expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode')
})
})
describe('getJobStatus', () => {
it('should fetch job status with correct endpoint', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
success: true,
taskId: 'task-123',
status: 'completed',
metadata: {
startedAt: '2024-01-01T00:00:00Z',
completedAt: '2024-01-01T00:01:00Z',
duration: 60000,
},
output: { result: 'done' },
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.getJobStatus('task-123')
expect(result).toHaveProperty('taskId', 'task-123')
expect(result).toHaveProperty('status', 'completed')
expect(result).toHaveProperty('output')
// Verify correct endpoint was called
const calls = vi.mocked(fetch.default).mock.calls
expect(calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123')
})
it('should handle job not found error', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
json: vi.fn().mockResolvedValue({
error: 'Job not found',
code: 'JOB_NOT_FOUND',
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await expect(client.getJobStatus('invalid-task')).rejects.toThrow(SimStudioError)
await expect(client.getJobStatus('invalid-task')).rejects.toThrow('Job not found')
})
})
describe('executeWithRetry', () => {
it('should succeed on first attempt when no rate limit', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
success: true,
output: { result: 'success' },
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.executeWithRetry('workflow-id', {
input: { message: 'test' },
})
expect(result).toHaveProperty('success', true)
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1)
})
it('should retry on rate limit error', async () => {
const fetch = await import('node-fetch')
// First call returns 429, second call succeeds
const rateLimitResponse = {
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: vi.fn().mockResolvedValue({
error: 'Rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
}),
headers: {
get: vi.fn((header: string) => {
if (header === 'retry-after') return '1'
if (header === 'x-ratelimit-limit') return '100'
if (header === 'x-ratelimit-remaining') return '0'
if (header === 'x-ratelimit-reset') return String(Math.floor(Date.now() / 1000) + 60)
return null
}),
},
}
const successResponse = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
success: true,
output: { result: 'success' },
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default)
.mockResolvedValueOnce(rateLimitResponse as any)
.mockResolvedValueOnce(successResponse as any)
const result = await client.executeWithRetry(
'workflow-id',
{ input: { message: 'test' } },
{ maxRetries: 3, initialDelay: 10 }
)
expect(result).toHaveProperty('success', true)
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(2)
})
it('should throw after max retries exceeded', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: vi.fn().mockResolvedValue({
error: 'Rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
}),
headers: {
get: vi.fn((header: string) => {
if (header === 'retry-after') return '1'
return null
}),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await expect(
client.executeWithRetry(
'workflow-id',
{ input: { message: 'test' } },
{ maxRetries: 2, initialDelay: 10 }
)
).rejects.toThrow('Rate limit exceeded')
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(3) // Initial + 2 retries
})
it('should not retry on non-rate-limit errors', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: vi.fn().mockResolvedValue({
error: 'Server error',
code: 'INTERNAL_ERROR',
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await expect(
client.executeWithRetry('workflow-id', { input: { message: 'test' } })
).rejects.toThrow('Server error')
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) // No retries
})
})
describe('getRateLimitInfo', () => {
it('should return null when no rate limit info available', () => {
const info = client.getRateLimitInfo()
expect(info).toBeNull()
})
it('should return rate limit info after API call', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ success: true, output: {} }),
headers: {
get: vi.fn((header: string) => {
if (header === 'x-ratelimit-limit') return '100'
if (header === 'x-ratelimit-remaining') return '95'
if (header === 'x-ratelimit-reset') return '1704067200'
return null
}),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await client.executeWorkflow('workflow-id', { input: {} })
const info = client.getRateLimitInfo()
expect(info).not.toBeNull()
expect(info?.limit).toBe(100)
expect(info?.remaining).toBe(95)
expect(info?.reset).toBe(1704067200)
})
})
describe('getUsageLimits', () => {
it('should fetch usage limits with correct structure', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
success: true,
rateLimit: {
sync: {
isLimited: false,
limit: 100,
remaining: 95,
resetAt: '2024-01-01T01:00:00Z',
},
async: {
isLimited: false,
limit: 50,
remaining: 48,
resetAt: '2024-01-01T01:00:00Z',
},
authType: 'api',
},
usage: {
currentPeriodCost: 1.23,
limit: 100.0,
plan: 'pro',
},
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.getUsageLimits()
expect(result.success).toBe(true)
expect(result.rateLimit.sync.limit).toBe(100)
expect(result.rateLimit.async.limit).toBe(50)
expect(result.usage.currentPeriodCost).toBe(1.23)
expect(result.usage.plan).toBe('pro')
// Verify correct endpoint was called
const calls = vi.mocked(fetch.default).mock.calls
expect(calls[0][0]).toBe('https://test.sim.ai/api/users/me/usage-limits')
})
it('should handle unauthorized error', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
json: vi.fn().mockResolvedValue({
error: 'Invalid API key',
code: 'UNAUTHORIZED',
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await expect(client.getUsageLimits()).rejects.toThrow(SimStudioError)
await expect(client.getUsageLimits()).rejects.toThrow('Invalid API key')
})
})
describe('executeWorkflow - streaming with selectedOutputs', () => {
it('should include stream and selectedOutputs in request body', async () => {
const fetch = await import('node-fetch')
const mockResponse = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
headers: {
get: vi.fn().mockReturnValue(null),
},
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await client.executeWorkflow('workflow-id', {
input: { message: 'test' },
stream: true,
selectedOutputs: ['agent1.content', 'agent2.content'],
})
const calls = vi.mocked(fetch.default).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
expect(requestBody).toHaveProperty('message', 'test')
expect(requestBody).toHaveProperty('stream', true)
expect(requestBody).toHaveProperty('selectedOutputs')
expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content'])
})
})
})
describe('SimStudioError', () => {

View File

@@ -29,6 +29,57 @@ export interface WorkflowStatus {
export interface ExecutionOptions {
input?: any
timeout?: number
stream?: boolean
selectedOutputs?: string[]
async?: boolean
}
export interface AsyncExecutionResult {
success: boolean
taskId: string
status: 'queued'
createdAt: string
links: {
status: string
}
}
export interface RateLimitInfo {
limit: number
remaining: number
reset: number
retryAfter?: number
}
export interface RetryOptions {
maxRetries?: number
initialDelay?: number
maxDelay?: number
backoffMultiplier?: number
}
export interface UsageLimits {
success: boolean
rateLimit: {
sync: {
isLimited: boolean
limit: number
remaining: number
resetAt: string
}
async: {
isLimited: boolean
limit: number
remaining: number
resetAt: string
}
authType: string
}
usage: {
currentPeriodCost: number
limit: number
plan: string
}
}
export class SimStudioError extends Error {
@@ -46,6 +97,7 @@ export class SimStudioError extends Error {
export class SimStudioClient {
private apiKey: string
private baseUrl: string
private rateLimitInfo: RateLimitInfo | null = null
constructor(config: SimStudioConfig) {
this.apiKey = config.apiKey
@@ -54,13 +106,14 @@ export class SimStudioClient {
/**
* Execute a workflow with optional input data
* If async is true, returns immediately with a task ID
*/
async executeWorkflow(
workflowId: string,
options: ExecutionOptions = {}
): Promise<WorkflowExecutionResult> {
): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
const url = `${this.baseUrl}/api/workflows/${workflowId}/execute`
const { input, timeout = 30000 } = options
const { input, timeout = 30000, stream, selectedOutputs, async } = options
try {
// Create a timeout promise
@@ -68,17 +121,45 @@ export class SimStudioClient {
setTimeout(() => reject(new Error('TIMEOUT')), timeout)
})
// Build request body - spread input at root level, then add API control parameters
const body: any = input !== undefined ? { ...input } : {}
if (stream !== undefined) {
body.stream = stream
}
if (selectedOutputs !== undefined) {
body.selectedOutputs = selectedOutputs
}
// Build headers - async execution uses X-Execution-Mode header
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
}
if (async) {
headers['X-Execution-Mode'] = 'async'
}
const fetchPromise = fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
},
body: JSON.stringify(input || {}),
headers,
body: JSON.stringify(body),
})
const response = await Promise.race([fetchPromise, timeoutPromise])
// Extract rate limit headers
this.updateRateLimitInfo(response)
// Handle rate limiting with retry
if (response.status === 429) {
const retryAfter = this.rateLimitInfo?.retryAfter || 1000
throw new SimStudioError(
`Rate limit exceeded. Retry after ${retryAfter}ms`,
'RATE_LIMIT_EXCEEDED',
429
)
}
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as unknown as any
throw new SimStudioError(
@@ -89,7 +170,7 @@ export class SimStudioClient {
}
const result = await response.json()
return result as WorkflowExecutionResult
return result as WorkflowExecutionResult | AsyncExecutionResult
} catch (error: any) {
if (error instanceof SimStudioError) {
throw error
@@ -144,9 +225,9 @@ export class SimStudioClient {
workflowId: string,
options: ExecutionOptions = {}
): Promise<WorkflowExecutionResult> {
// For now, the API is synchronous, so we just execute directly
// In the future, if async execution is added, this method can be enhanced
return this.executeWorkflow(workflowId, options)
// Ensure sync mode by explicitly setting async to false
const syncOptions = { ...options, async: false }
return this.executeWorkflow(workflowId, syncOptions) as Promise<WorkflowExecutionResult>
}
/**
@@ -174,6 +255,158 @@ export class SimStudioClient {
setBaseUrl(baseUrl: string): void {
this.baseUrl = baseUrl.replace(/\/+$/, '')
}
/**
* Get the status of an async job
* @param taskId The task ID returned from async execution
*/
async getJobStatus(taskId: string): Promise<any> {
const url = `${this.baseUrl}/api/jobs/${taskId}`
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'X-API-Key': this.apiKey,
},
})
this.updateRateLimitInfo(response)
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as unknown as any
throw new SimStudioError(
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
errorData.code,
response.status
)
}
const result = await response.json()
return result
} catch (error: any) {
if (error instanceof SimStudioError) {
throw error
}
throw new SimStudioError(error?.message || 'Failed to get job status', 'STATUS_ERROR')
}
}
/**
* Execute workflow with automatic retry on rate limit
*/
async executeWithRetry(
workflowId: string,
options: ExecutionOptions = {},
retryOptions: RetryOptions = {}
): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
const {
maxRetries = 3,
initialDelay = 1000,
maxDelay = 30000,
backoffMultiplier = 2,
} = retryOptions
let lastError: SimStudioError | null = null
let delay = initialDelay
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await this.executeWorkflow(workflowId, options)
} catch (error: any) {
if (!(error instanceof SimStudioError) || error.code !== 'RATE_LIMIT_EXCEEDED') {
throw error
}
lastError = error
// Don't retry after last attempt
if (attempt === maxRetries) {
break
}
// Use retry-after if provided, otherwise use exponential backoff
const waitTime =
error.status === 429 && this.rateLimitInfo?.retryAfter
? this.rateLimitInfo.retryAfter
: Math.min(delay, maxDelay)
// Add jitter (±25%)
const jitter = waitTime * (0.75 + Math.random() * 0.5)
await new Promise((resolve) => setTimeout(resolve, jitter))
// Exponential backoff for next attempt
delay *= backoffMultiplier
}
}
throw lastError || new SimStudioError('Max retries exceeded', 'MAX_RETRIES_EXCEEDED')
}
/**
* Get current rate limit information
*/
getRateLimitInfo(): RateLimitInfo | null {
return this.rateLimitInfo
}
/**
* Update rate limit info from response headers
* @private
*/
private updateRateLimitInfo(response: any): void {
const limit = response.headers.get('x-ratelimit-limit')
const remaining = response.headers.get('x-ratelimit-remaining')
const reset = response.headers.get('x-ratelimit-reset')
const retryAfter = response.headers.get('retry-after')
if (limit || remaining || reset) {
this.rateLimitInfo = {
limit: limit ? Number.parseInt(limit, 10) : 0,
remaining: remaining ? Number.parseInt(remaining, 10) : 0,
reset: reset ? Number.parseInt(reset, 10) : 0,
retryAfter: retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : undefined,
}
}
}
/**
* Get current usage limits and quota information
*/
async getUsageLimits(): Promise<UsageLimits> {
const url = `${this.baseUrl}/api/users/me/usage-limits`
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'X-API-Key': this.apiKey,
},
})
this.updateRateLimitInfo(response)
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as unknown as any
throw new SimStudioError(
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
errorData.code,
response.status
)
}
const result = await response.json()
return result as UsageLimits
} catch (error: any) {
if (error instanceof SimStudioError) {
throw error
}
throw new SimStudioError(error?.message || 'Failed to get usage limits', 'USAGE_ERROR')
}
}
}
// Export types and classes