mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}%)`
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 = '',
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
181
apps/sim/lib/workflows/streaming.ts
Normal file
181
apps/sim/lib/workflows/streaming.ts
Normal 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()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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("6️⃣ Error Handling Example:")
|
||||
|
||||
print("6️⃣ Streaming 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!")
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user