mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-25 06:48:12 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf5ed4586 | ||
|
|
dc0ed842c4 | ||
|
|
1952b196a0 | ||
|
|
fa03d4d818 | ||
|
|
e14cebeec5 | ||
|
|
404d8c006e |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -27,10 +27,11 @@ jobs:
|
||||
steps:
|
||||
- name: Extract version from commit message
|
||||
id: extract
|
||||
env:
|
||||
COMMIT_MSG: ${{ github.event.head_commit.message }}
|
||||
run: |
|
||||
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||
# Only tag versions on main branch
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "is_release=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -119,6 +119,19 @@ aside#nd-sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide TOC popover on tablet/medium screens (768px - 1279px) */
|
||||
/* Keeps it visible on mobile (<768px) for easy navigation */
|
||||
/* Desktop (>=1280px) already hides it via fumadocs xl:hidden */
|
||||
@media (min-width: 768px) and (max-width: 1279px) {
|
||||
#nd-docs-layout {
|
||||
--fd-toc-popover-height: 0px !important;
|
||||
}
|
||||
|
||||
[data-toc-popover] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop only: Apply custom navbar offset, sidebar width and margin offsets */
|
||||
/* On mobile, let fumadocs handle the layout natively */
|
||||
@media (min-width: 1024px) {
|
||||
|
||||
@@ -5,45 +5,25 @@ title: Copilot
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react'
|
||||
import { MessageCircle, Hammer, Zap, Globe, Paperclip, History, RotateCcw, Brain } from 'lucide-react'
|
||||
|
||||
Copilot is your in-editor assistant that helps you build and edit workflows with Sim Copilot, as well as understand and improve them. It can:
|
||||
Copilot is your in-editor assistant that helps you build and edit workflows. It can:
|
||||
|
||||
- **Explain**: Answer questions about Sim and your current workflow
|
||||
- **Guide**: Suggest edits and best practices
|
||||
- **Edit**: Make changes to blocks, connections, and settings when you approve
|
||||
- **Build**: Add blocks, wire connections, and configure settings
|
||||
- **Debug**: Analyze execution issues and optimize performance
|
||||
|
||||
<Callout type="info">
|
||||
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
|
||||
Copilot is a Sim-managed service. For self-hosted deployments:
|
||||
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
|
||||
2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
2. Set `COPILOT_API_KEY` in your self-hosted environment
|
||||
</Callout>
|
||||
|
||||
## Context Menu (@)
|
||||
|
||||
Use the `@` symbol to reference various resources and give Copilot more context about your workspace:
|
||||
|
||||
<Image
|
||||
src="/static/copilot/copilot-menu.png"
|
||||
alt="Copilot context menu showing available reference options"
|
||||
width={600}
|
||||
height={400}
|
||||
/>
|
||||
|
||||
The `@` menu provides access to:
|
||||
- **Chats**: Reference previous copilot conversations
|
||||
- **All workflows**: Reference any workflow in your workspace
|
||||
- **Workflow Blocks**: Reference specific blocks from workflows
|
||||
- **Blocks**: Reference block types and templates
|
||||
- **Knowledge**: Reference your uploaded documents and knowledgebase
|
||||
- **Docs**: Reference Sim documentation
|
||||
- **Templates**: Reference workflow templates
|
||||
- **Logs**: Reference execution logs and results
|
||||
|
||||
This contextual information helps Copilot provide more accurate and relevant assistance for your specific use case.
|
||||
|
||||
## Modes
|
||||
|
||||
Switch between modes using the mode selector at the bottom of the input area.
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title={
|
||||
@@ -60,113 +40,153 @@ This contextual information helps Copilot provide more accurate and relevant ass
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
Agent
|
||||
<Hammer className="h-4 w-4 text-muted-foreground" />
|
||||
Build
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">
|
||||
Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve.
|
||||
Workflow building mode. Copilot can add blocks, wire connections, edit configurations, and debug issues.
|
||||
</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/copilot/copilot-mode.png"
|
||||
alt="Copilot mode selection interface"
|
||||
width={600}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
## Models
|
||||
|
||||
## Depth Levels
|
||||
Select your preferred AI model using the model selector at the bottom right of the input area.
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
Fast
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<InfinityIcon className="h-4 w-4 text-muted-foreground" />
|
||||
Auto
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">Balanced speed and reasoning. Recommended default for most tasks.</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-muted-foreground" />
|
||||
Advanced
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">More reasoning for larger workflows and complex edits while staying performant.</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
|
||||
Behemoth
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
**Available Models:**
|
||||
- Claude 4.5 Opus, Sonnet (default), Haiku
|
||||
- GPT 5.2 Codex, Pro
|
||||
- Gemini 3 Pro
|
||||
|
||||
### Mode Selection Interface
|
||||
Choose based on your needs: faster models for simple tasks, more capable models for complex workflows.
|
||||
|
||||
You can easily switch between different reasoning modes using the mode selector in the Copilot interface:
|
||||
## Context Menu (@)
|
||||
|
||||
<Image
|
||||
src="/static/copilot/copilot-models.png"
|
||||
alt="Copilot mode selection showing Advanced mode with MAX toggle"
|
||||
width={600}
|
||||
height={300}
|
||||
/>
|
||||
Use the `@` symbol to reference resources and give Copilot more context:
|
||||
|
||||
The interface allows you to:
|
||||
- **Select reasoning level**: Choose from Fast, Auto, Advanced, or Behemoth
|
||||
- **Enable MAX mode**: Toggle for maximum reasoning capabilities when you need the most thorough analysis
|
||||
- **See mode descriptions**: Understand what each mode is optimized for
|
||||
| Reference | Description |
|
||||
|-----------|-------------|
|
||||
| **Chats** | Previous copilot conversations |
|
||||
| **Workflows** | Any workflow in your workspace |
|
||||
| **Workflow Blocks** | Blocks in the current workflow |
|
||||
| **Blocks** | Block types and templates |
|
||||
| **Knowledge** | Uploaded documents and knowledge bases |
|
||||
| **Docs** | Sim documentation |
|
||||
| **Templates** | Workflow templates |
|
||||
| **Logs** | Execution logs and results |
|
||||
|
||||
Choose your mode based on the complexity of your task - use Fast for simple questions and Behemoth for complex architectural changes.
|
||||
Type `@` in the input field to open the context menu, then search or browse to find what you need.
|
||||
|
||||
## Billing and Cost Calculation
|
||||
## Slash Commands (/)
|
||||
|
||||
### How Costs Are Calculated
|
||||
Use slash commands for quick actions:
|
||||
|
||||
Copilot usage is billed per token from the underlying LLM:
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/fast` | Fast mode execution |
|
||||
| `/research` | Research and exploration mode |
|
||||
| `/actions` | Execute agent actions |
|
||||
|
||||
- **Input tokens**: billed at the provider's base rate (**at-cost**)
|
||||
- **Output tokens**: billed at **1.5×** the provider's base output rate
|
||||
**Web Commands:**
|
||||
|
||||
```javascript
|
||||
copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000
|
||||
```
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/search` | Search the web |
|
||||
| `/read` | Read a specific URL |
|
||||
| `/scrape` | Scrape web page content |
|
||||
| `/crawl` | Crawl multiple pages |
|
||||
|
||||
| Component | Rate Applied |
|
||||
|----------|----------------------|
|
||||
| Input | inputPrice |
|
||||
| Output | outputPrice × 1.5 |
|
||||
Type `/` in the input field to see available commands.
|
||||
|
||||
<Callout type="warning">
|
||||
Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing.
|
||||
</Callout>
|
||||
## Chat Management
|
||||
|
||||
### Starting a New Chat
|
||||
|
||||
Click the **+** button in the Copilot header to start a fresh conversation.
|
||||
|
||||
### Chat History
|
||||
|
||||
Click **History** to view previous conversations grouped by date. You can:
|
||||
- Click a chat to resume it
|
||||
- Delete chats you no longer need
|
||||
|
||||
### Editing Messages
|
||||
|
||||
Hover over any of your messages and click **Edit** to modify and resend it. This is useful for refining your prompts.
|
||||
|
||||
### Message Queue
|
||||
|
||||
If you send a message while Copilot is still responding, it gets queued. You can:
|
||||
- View queued messages in the expandable queue panel
|
||||
- Send a queued message immediately (aborts current response)
|
||||
- Remove messages from the queue
|
||||
|
||||
## File Attachments
|
||||
|
||||
Click the attachment icon to upload files with your message. Supported file types include:
|
||||
- Images (preview thumbnails shown)
|
||||
- PDFs
|
||||
- Text files, JSON, XML
|
||||
- Other document formats
|
||||
|
||||
Files are displayed as clickable thumbnails that open in a new tab.
|
||||
|
||||
## Checkpoints & Changes
|
||||
|
||||
When Copilot makes changes to your workflow, it saves checkpoints so you can revert if needed.
|
||||
|
||||
### Viewing Checkpoints
|
||||
|
||||
Hover over a Copilot message and click the checkpoints icon to see saved workflow states for that message.
|
||||
|
||||
### Reverting Changes
|
||||
|
||||
Click **Revert** on any checkpoint to restore your workflow to that state. A confirmation dialog will warn that this action cannot be undone.
|
||||
|
||||
### Accepting Changes
|
||||
|
||||
When Copilot proposes changes, you can:
|
||||
- **Accept**: Apply the proposed changes (`Mod+Shift+Enter`)
|
||||
- **Reject**: Dismiss the changes and keep your current workflow
|
||||
|
||||
## Thinking Blocks
|
||||
|
||||
For complex requests, Copilot may show its reasoning process in expandable thinking blocks:
|
||||
|
||||
- Blocks auto-expand while Copilot is thinking
|
||||
- Click to manually expand/collapse
|
||||
- Shows duration of the thinking process
|
||||
- Helps you understand how Copilot arrived at its solution
|
||||
|
||||
## Options Selection
|
||||
|
||||
When Copilot presents multiple options, you can select using:
|
||||
|
||||
| Control | Action |
|
||||
|---------|--------|
|
||||
| **1-9** | Select option by number |
|
||||
| **Arrow Up/Down** | Navigate between options |
|
||||
| **Enter** | Select highlighted option |
|
||||
|
||||
Selected options are highlighted; unselected options appear struck through.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `@` | Open context menu |
|
||||
| `/` | Open slash commands |
|
||||
| `Arrow Up/Down` | Navigate menu items |
|
||||
| `Enter` | Select menu item |
|
||||
| `Esc` | Close menus |
|
||||
| `Mod+Shift+Enter` | Accept Copilot changes |
|
||||
|
||||
## Usage Limits
|
||||
|
||||
Copilot usage is billed per token from the underlying LLM. If you reach your usage limit, Copilot will prompt you to increase your limit. You can add usage in increments ($50, $100) from your current base.
|
||||
|
||||
<Callout type="info">
|
||||
Model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. See <a href="/execution/costs">the Cost Calculation page</a> for background and examples.
|
||||
See the [Cost Calculation page](/execution/costs) for billing details.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls
|
||||
| `Mod` + `V` | Paste blocks |
|
||||
| `Delete` or `Backspace` | Delete selected blocks or edges |
|
||||
| `Shift` + `L` | Auto-layout canvas |
|
||||
| `Mod` + `Shift` + `F` | Fit to view |
|
||||
| `Mod` + `Shift` + `Enter` | Accept Copilot changes |
|
||||
|
||||
## Panel Navigation
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"pages": [
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
|
||||
136
apps/docs/content/docs/en/quick-reference/index.mdx
Normal file
136
apps/docs/content/docs/en/quick-reference/index.mdx
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: Quick Reference
|
||||
description: Essential actions for navigating and using the Sim workflow editor
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts).
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
|
||||
</Callout>
|
||||
|
||||
## Workspaces
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Create a workspace | Click workspace dropdown in sidebar → **New Workspace** |
|
||||
| Rename a workspace | Workspace settings → Edit name |
|
||||
| Switch workspaces | Click workspace dropdown in sidebar → Select workspace |
|
||||
| Invite team members | Workspace settings → **Team** → **Invite** |
|
||||
|
||||
## Workflows
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Create a workflow | Click **New Workflow** button or `Mod+Shift+A` |
|
||||
| Rename a workflow | Double-click workflow name in sidebar, or right-click → **Rename** |
|
||||
| Duplicate a workflow | Right-click workflow → **Duplicate** |
|
||||
| Reorder workflows | Drag workflow up/down in the sidebar list |
|
||||
| Import a workflow | Sidebar menu → **Import** → Select file |
|
||||
| Create a folder | Right-click in sidebar → **New Folder** |
|
||||
| Rename a folder | Right-click folder → **Rename** |
|
||||
| Delete a folder | Right-click folder → **Delete** |
|
||||
| Collapse/expand folder | Click folder arrow, or double-click folder |
|
||||
| Move workflow to folder | Drag workflow onto folder in sidebar |
|
||||
| Delete a workflow | Right-click workflow → **Delete** |
|
||||
| Export a workflow | Right-click workflow → **Export** |
|
||||
| Assign workflow color | Right-click workflow → **Change Color** |
|
||||
| Multi-select workflows | `Mod+Click` or `Shift+Click` workflows in sidebar |
|
||||
| Open in new tab | Right-click workflow → **Open in New Tab** |
|
||||
|
||||
## Blocks
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Add a block | Drag from Toolbar panel, or right-click canvas → **Add Block** |
|
||||
| Select a block | Click on the block |
|
||||
| Multi-select blocks | `Mod+Click` additional blocks, or right-drag to draw selection box |
|
||||
| Move blocks | Drag selected block(s) to new position |
|
||||
| Copy blocks | `Mod+C` with blocks selected |
|
||||
| Paste blocks | `Mod+V` to paste copied blocks |
|
||||
| Duplicate blocks | Right-click → **Duplicate** |
|
||||
| Delete blocks | `Delete` or `Backspace` key, or right-click → **Delete** |
|
||||
| Rename a block | Click block name in header, or edit in the Editor panel |
|
||||
| Enable/Disable a block | Right-click → **Enable/Disable** |
|
||||
| Toggle handle orientation | Right-click → **Toggle Handles** |
|
||||
| Toggle trigger mode | Right-click trigger block → **Toggle Trigger Mode** |
|
||||
| Configure a block | Select block → use Editor panel on right |
|
||||
|
||||
## Connections
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Create a connection | Drag from output handle to input handle |
|
||||
| Delete a connection | Click edge to select → `Delete` key |
|
||||
| Use output in another block | Drag connection tag into input field |
|
||||
|
||||
## Canvas Navigation
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Pan/move canvas | Left-drag on empty space, or scroll/trackpad |
|
||||
| Zoom in/out | Scroll wheel or pinch gesture |
|
||||
| Auto-layout | `Shift+L` |
|
||||
| Draw selection box | Right-drag on empty canvas area |
|
||||
|
||||
## Panels & Views
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Open Copilot tab | Press `C` or click Copilot tab |
|
||||
| Open Toolbar tab | Press `T` or click Toolbar tab |
|
||||
| Open Editor tab | Press `E` or click Editor tab |
|
||||
| Search toolbar | `Mod+F` |
|
||||
| Toggle advanced mode | Click toggle button on input fields |
|
||||
| Resize panels | Drag panel edge |
|
||||
| Collapse/expand sidebar | Click collapse button on sidebar |
|
||||
|
||||
## Running & Testing
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Run workflow | Click Play button or `Mod+Enter` |
|
||||
| Stop workflow | Click Stop button or `Mod+Enter` while running |
|
||||
| Test with chat | Use Chat panel on the right side |
|
||||
| Select output to view | Click dropdown in Chat panel → Select block output |
|
||||
| Clear chat history | Click clear button in Chat panel |
|
||||
| View execution logs | Open terminal panel at bottom, or `Mod+L` |
|
||||
| Filter logs by block | Click block filter in terminal |
|
||||
| Filter logs by status | Click status filter in terminal |
|
||||
| Search logs | Use search field in terminal |
|
||||
| Copy log entry | Right-click log entry → **Copy** |
|
||||
| Clear terminal | `Mod+D` |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Deploy a workflow | Click **Deploy** button in Deploy tab |
|
||||
| Update deployment | Click **Update** when changes are detected |
|
||||
| View deployment status | Check status indicator (Live/Update/Deploy) in Deploy tab |
|
||||
| Revert deployment | Access previous versions in Deploy tab |
|
||||
| Copy webhook URL | Deploy tab → Copy webhook URL |
|
||||
| Copy API endpoint | Deploy tab → Copy API endpoint URL |
|
||||
| Set up a schedule | Add Schedule trigger block → Configure interval |
|
||||
|
||||
## Variables
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Add workflow variable | Variables tab → **Add Variable** |
|
||||
| Edit workflow variable | Variables tab → Click variable to edit |
|
||||
| Delete workflow variable | Variables tab → Click delete icon on variable |
|
||||
| Add environment variable | Settings → **Environment Variables** → **Add** |
|
||||
| Reference a variable | Use `{{variableName}}` syntax in block inputs |
|
||||
|
||||
## Credentials
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Add API key | Block credential field → **Add Credential** → Enter API key |
|
||||
| Connect OAuth account | Block credential field → **Connect** → Authorize with provider |
|
||||
| Manage credentials | Settings → **Credentials** |
|
||||
| Remove credential | Settings → **Credentials** → Delete credential |
|
||||
|
||||
@@ -284,22 +284,37 @@ const renderLabel = (
|
||||
</>
|
||||
)}
|
||||
{showCanonicalToggle && (
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
onClick={canonicalToggle?.onToggle}
|
||||
disabled={canonicalToggleDisabledResolved}
|
||||
aria-label={canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={cn(
|
||||
'!h-[12px] !w-[12px]',
|
||||
canonicalToggle?.mode === 'advanced'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
onClick={canonicalToggle?.onToggle}
|
||||
disabled={canonicalToggleDisabledResolved}
|
||||
aria-label={
|
||||
canonicalToggle?.mode === 'advanced'
|
||||
? 'Switch to selector'
|
||||
: 'Switch to manual ID'
|
||||
}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={cn(
|
||||
'!h-[12px] !w-[12px]',
|
||||
canonicalToggle?.mode === 'advanced'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>
|
||||
{canonicalToggle?.mode === 'advanced'
|
||||
? 'Switch to selector'
|
||||
: 'Switch to manual ID'}
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,11 @@ interface UseCanvasContextMenuProps {
|
||||
|
||||
/**
|
||||
* Hook for managing workflow canvas context menus.
|
||||
* Handles right-click events, menu state, click-outside detection, and block info extraction.
|
||||
*
|
||||
* Handles right-click events on nodes, pane, and selections with proper multi-select behavior.
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Context menu state and handlers
|
||||
*/
|
||||
export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasContextMenuProps) {
|
||||
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
|
||||
@@ -46,19 +50,29 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
|
||||
event.stopPropagation()
|
||||
|
||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
|
||||
}))
|
||||
)
|
||||
const currentSelectedNodes = getNodes().filter((n) => n.selected)
|
||||
const isClickedNodeSelected = currentSelectedNodes.some((n) => n.id === node.id)
|
||||
|
||||
const selectedNodes = getNodes().filter((n) => n.selected)
|
||||
const nodesToUse = isMultiSelect
|
||||
? selectedNodes.some((n) => n.id === node.id)
|
||||
? selectedNodes
|
||||
: [...selectedNodes, node]
|
||||
: [node]
|
||||
let nodesToUse: Node[]
|
||||
if (isClickedNodeSelected) {
|
||||
nodesToUse = currentSelectedNodes
|
||||
} else if (isMultiSelect) {
|
||||
nodesToUse = [...currentSelectedNodes, node]
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
selected: n.id === node.id ? true : n.selected,
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
nodesToUse = [node]
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
selected: n.id === node.id,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
setPosition({ x: event.clientX, y: event.clientY })
|
||||
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
|
||||
|
||||
@@ -27,18 +27,13 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
// Used to prevent click-outside dismissal when trigger is clicked
|
||||
const dismissPreventedRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Handle right-click event
|
||||
*/
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Calculate position relative to viewport
|
||||
const x = e.clientX
|
||||
const y = e.clientY
|
||||
|
||||
@@ -50,17 +45,10 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
[onContextMenu]
|
||||
)
|
||||
|
||||
/**
|
||||
* Close the context menu
|
||||
*/
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Prevent the next click-outside from dismissing the menu.
|
||||
* Call this on pointerdown of a toggle trigger to allow proper toggle behavior.
|
||||
*/
|
||||
const preventDismiss = useCallback(() => {
|
||||
dismissPreventedRef.current = true
|
||||
}, [])
|
||||
@@ -72,7 +60,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
// Check if dismissal was prevented (e.g., by toggle trigger's pointerdown)
|
||||
if (dismissPreventedRef.current) {
|
||||
dismissPreventedRef.current = false
|
||||
return
|
||||
@@ -82,7 +69,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to prevent immediate close from the same click that opened the menu
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
|
||||
@@ -11,9 +11,10 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
|
||||
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
|
||||
import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { getAllBlocks, getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
@@ -667,11 +668,47 @@ function createBlockFromParams(
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (validatedInputs) {
|
||||
updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return blockState
|
||||
}
|
||||
|
||||
function updateCanonicalModesForInputs(
|
||||
block: { data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> } },
|
||||
inputKeys: string[],
|
||||
blockConfig: BlockConfig
|
||||
): void {
|
||||
if (!blockConfig.subBlocks?.length) return
|
||||
|
||||
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
|
||||
const canonicalModeUpdates: Record<string, 'basic' | 'advanced'> = {}
|
||||
|
||||
for (const inputKey of inputKeys) {
|
||||
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[inputKey]
|
||||
if (!canonicalId) continue
|
||||
|
||||
const group = canonicalIndex.groupsById[canonicalId]
|
||||
if (!group || !isCanonicalPair(group)) continue
|
||||
|
||||
const isAdvanced = group.advancedIds.includes(inputKey)
|
||||
const existingMode = canonicalModeUpdates[canonicalId]
|
||||
|
||||
if (!existingMode || isAdvanced) {
|
||||
canonicalModeUpdates[canonicalId] = isAdvanced ? 'advanced' : 'basic'
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(canonicalModeUpdates).length > 0) {
|
||||
if (!block.data) block.data = {}
|
||||
if (!block.data.canonicalModes) block.data.canonicalModes = {}
|
||||
Object.assign(block.data.canonicalModes, canonicalModeUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize tools array by adding back fields that were sanitized for training
|
||||
*/
|
||||
@@ -1654,6 +1691,15 @@ function applyOperationsToWorkflowState(
|
||||
block.data.collection = params.inputs.collection
|
||||
}
|
||||
}
|
||||
|
||||
const editBlockConfig = getBlock(block.type)
|
||||
if (editBlockConfig) {
|
||||
updateCanonicalModesForInputs(
|
||||
block,
|
||||
Object.keys(validationResult.validInputs),
|
||||
editBlockConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update basic properties
|
||||
@@ -2256,6 +2302,15 @@ function applyOperationsToWorkflowState(
|
||||
existingBlock.subBlocks[key].value = sanitizedValue
|
||||
}
|
||||
})
|
||||
|
||||
const existingBlockConfig = getBlock(existingBlock.type)
|
||||
if (existingBlockConfig) {
|
||||
updateCanonicalModesForInputs(
|
||||
existingBlock,
|
||||
Object.keys(validationResult.validInputs),
|
||||
existingBlockConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Special container types (loop, parallel) are not in the block registry but are valid
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
|
||||
5
packages/python-sdk/.gitignore
vendored
5
packages/python-sdk/.gitignore
vendored
@@ -81,4 +81,7 @@ Thumbs.db
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# uv
|
||||
uv.lock
|
||||
@@ -43,24 +43,30 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
|
||||
|
||||
#### Methods
|
||||
|
||||
##### execute_workflow(workflow_id, input_data=None, timeout=30.0)
|
||||
##### execute_workflow(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None)
|
||||
|
||||
Execute a workflow with optional input data.
|
||||
|
||||
```python
|
||||
result = client.execute_workflow(
|
||||
"workflow-id",
|
||||
input_data={"message": "Hello, world!"},
|
||||
timeout=30.0 # 30 seconds
|
||||
)
|
||||
# With dict input (spread at root level of request body)
|
||||
result = client.execute_workflow("workflow-id", {"message": "Hello, world!"})
|
||||
|
||||
# With primitive input (wrapped as { input: value })
|
||||
result = client.execute_workflow("workflow-id", "NVDA")
|
||||
|
||||
# With options (keyword-only arguments)
|
||||
result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60.0)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow to execute
|
||||
- `input_data` (dict, optional): Input data to pass to the workflow. File objects are automatically converted to base64.
|
||||
- `timeout` (float): Timeout in seconds (default: 30.0)
|
||||
- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64.
|
||||
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
|
||||
- `stream` (bool, keyword-only): Enable streaming responses
|
||||
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
|
||||
- `async_execution` (bool, keyword-only): Execute asynchronously and return execution ID
|
||||
|
||||
**Returns:** `WorkflowExecutionResult`
|
||||
**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
|
||||
|
||||
##### get_workflow_status(workflow_id)
|
||||
|
||||
@@ -92,24 +98,89 @@ if is_ready:
|
||||
|
||||
**Returns:** `bool`
|
||||
|
||||
##### execute_workflow_sync(workflow_id, input_data=None, timeout=30.0)
|
||||
##### execute_workflow_sync(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None)
|
||||
|
||||
Execute a workflow and poll for completion (useful for long-running workflows).
|
||||
Execute a workflow synchronously (ensures non-async mode).
|
||||
|
||||
```python
|
||||
result = client.execute_workflow_sync(
|
||||
result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, timeout=60.0)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow to execute
|
||||
- `input` (any, optional): Input data to pass to the workflow
|
||||
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
|
||||
- `stream` (bool, keyword-only): Enable streaming responses
|
||||
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
|
||||
|
||||
**Returns:** `WorkflowExecutionResult`
|
||||
|
||||
##### get_job_status(task_id)
|
||||
|
||||
Get the status of an async job.
|
||||
|
||||
```python
|
||||
status = client.get_job_status("task-id-from-async-execution")
|
||||
print("Job status:", status)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `task_id` (str): The task ID returned from async execution
|
||||
|
||||
**Returns:** `dict`
|
||||
|
||||
##### execute_with_retry(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None, max_retries=3, initial_delay=1.0, max_delay=30.0, backoff_multiplier=2.0)
|
||||
|
||||
Execute a workflow with automatic retry on rate limit errors.
|
||||
|
||||
```python
|
||||
result = client.execute_with_retry(
|
||||
"workflow-id",
|
||||
input_data={"data": "some input"},
|
||||
timeout=60.0
|
||||
{"message": "Hello"},
|
||||
timeout=30.0,
|
||||
max_retries=3,
|
||||
initial_delay=1.0,
|
||||
max_delay=30.0,
|
||||
backoff_multiplier=2.0
|
||||
)
|
||||
```
|
||||
|
||||
**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
|
||||
- `input` (any, optional): Input data to pass to the workflow
|
||||
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
|
||||
- `stream` (bool, keyword-only): Enable streaming responses
|
||||
- `selected_outputs` (list, keyword-only): Block outputs to stream
|
||||
- `async_execution` (bool, keyword-only): Execute asynchronously
|
||||
- `max_retries` (int, keyword-only): Maximum retry attempts (default: 3)
|
||||
- `initial_delay` (float, keyword-only): Initial delay in seconds (default: 1.0)
|
||||
- `max_delay` (float, keyword-only): Maximum delay in seconds (default: 30.0)
|
||||
- `backoff_multiplier` (float, keyword-only): Backoff multiplier (default: 2.0)
|
||||
|
||||
**Returns:** `WorkflowExecutionResult`
|
||||
**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
|
||||
|
||||
##### get_rate_limit_info()
|
||||
|
||||
Get current rate limit information from the last API response.
|
||||
|
||||
```python
|
||||
rate_info = client.get_rate_limit_info()
|
||||
if rate_info:
|
||||
print("Remaining requests:", rate_info.remaining)
|
||||
```
|
||||
|
||||
**Returns:** `RateLimitInfo` or `None`
|
||||
|
||||
##### get_usage_limits()
|
||||
|
||||
Get current usage limits and quota information.
|
||||
|
||||
```python
|
||||
limits = client.get_usage_limits()
|
||||
print("Current usage:", limits.usage)
|
||||
```
|
||||
|
||||
**Returns:** `UsageLimits`
|
||||
|
||||
##### set_api_key(api_key)
|
||||
|
||||
@@ -171,6 +242,39 @@ class SimStudioError(Exception):
|
||||
self.status = status
|
||||
```
|
||||
|
||||
### AsyncExecutionResult
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AsyncExecutionResult:
|
||||
success: bool
|
||||
task_id: str
|
||||
status: str # 'queued'
|
||||
created_at: str
|
||||
links: Dict[str, str]
|
||||
```
|
||||
|
||||
### 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]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Workflow Execution
|
||||
@@ -191,7 +295,7 @@ def run_workflow():
|
||||
# Execute the workflow
|
||||
result = client.execute_workflow(
|
||||
"my-workflow-id",
|
||||
input_data={
|
||||
{
|
||||
"message": "Process this data",
|
||||
"user_id": "12345"
|
||||
}
|
||||
@@ -298,7 +402,7 @@ client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
with open('document.pdf', 'rb') as f:
|
||||
result = client.execute_workflow(
|
||||
'workflow-id',
|
||||
input_data={
|
||||
{
|
||||
'documents': [f], # Must match your workflow's "files" field name
|
||||
'instructions': 'Analyze this document'
|
||||
}
|
||||
@@ -308,7 +412,7 @@ with open('document.pdf', 'rb') as f:
|
||||
with open('doc1.pdf', 'rb') as f1, open('doc2.pdf', 'rb') as f2:
|
||||
result = client.execute_workflow(
|
||||
'workflow-id',
|
||||
input_data={
|
||||
{
|
||||
'attachments': [f1, f2], # Must match your workflow's "files" field name
|
||||
'query': 'Compare these documents'
|
||||
}
|
||||
@@ -327,14 +431,14 @@ def execute_workflows_batch(workflow_data_pairs):
|
||||
"""Execute multiple workflows with different input data."""
|
||||
results = []
|
||||
|
||||
for workflow_id, input_data in workflow_data_pairs:
|
||||
for workflow_id, workflow_input in workflow_data_pairs:
|
||||
try:
|
||||
# Validate workflow before execution
|
||||
if not client.validate_workflow(workflow_id):
|
||||
print(f"Skipping {workflow_id}: not deployed")
|
||||
continue
|
||||
|
||||
result = client.execute_workflow(workflow_id, input_data)
|
||||
result = client.execute_workflow(workflow_id, workflow_input)
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
"success": result.success,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "simstudio-sdk"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
authors = [
|
||||
{name = "Sim", email = "help@sim.ai"},
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ import os
|
||||
import requests
|
||||
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.2"
|
||||
__all__ = [
|
||||
"SimStudioClient",
|
||||
"SimStudioError",
|
||||
@@ -64,15 +64,6 @@ class RateLimitInfo:
|
||||
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."""
|
||||
@@ -115,7 +106,6 @@ class SimStudioClient:
|
||||
Recursively processes nested dicts and lists.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
|
||||
# Check if this is a file-like object
|
||||
if hasattr(value, 'read') and callable(value.read):
|
||||
@@ -159,7 +149,8 @@ class SimStudioClient:
|
||||
def execute_workflow(
|
||||
self,
|
||||
workflow_id: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
input: Optional[Any] = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
stream: Optional[bool] = None,
|
||||
selected_outputs: Optional[list] = None,
|
||||
@@ -169,11 +160,13 @@ class SimStudioClient:
|
||||
Execute a workflow with optional input data.
|
||||
If async_execution is True, returns immediately with a task ID.
|
||||
|
||||
File objects in input_data will be automatically detected and converted to base64.
|
||||
File objects in input will be automatically detected and converted to base64.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input_data: Input data to pass to the workflow (can include file-like objects)
|
||||
input: Input data to pass to the workflow. Can be a dict (spread at root level),
|
||||
primitive value (string, number, bool), or list (wrapped in 'input' field).
|
||||
File-like objects within dicts are automatically converted to base64.
|
||||
timeout: Timeout in seconds (default: 30.0)
|
||||
stream: Enable streaming responses (default: None)
|
||||
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
|
||||
@@ -193,8 +186,15 @@ class SimStudioClient:
|
||||
headers['X-Execution-Mode'] = 'async'
|
||||
|
||||
try:
|
||||
# Build JSON body - spread input at root level, then add API control parameters
|
||||
body = input_data.copy() if input_data is not None else {}
|
||||
# Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field
|
||||
body = {}
|
||||
if input is not None:
|
||||
if isinstance(input, dict):
|
||||
# Dict input: spread at root level (matches curl/API behavior)
|
||||
body = input.copy()
|
||||
else:
|
||||
# Primitive or list input: wrap in 'input' field
|
||||
body = {'input': input}
|
||||
|
||||
# Convert any file objects in the input to base64 format
|
||||
body = self._convert_files_to_base64(body)
|
||||
@@ -320,20 +320,18 @@ class SimStudioClient:
|
||||
def execute_workflow_sync(
|
||||
self,
|
||||
workflow_id: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
input: Optional[Any] = None,
|
||||
*,
|
||||
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.
|
||||
Execute a workflow synchronously (ensures non-async mode).
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input_data: Input data to pass to the workflow (can include file-like objects)
|
||||
input: Input data to pass to the workflow (can include file-like objects)
|
||||
timeout: Timeout for the initial request in seconds
|
||||
stream: Enable streaming responses (default: None)
|
||||
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
|
||||
@@ -344,9 +342,14 @@ class SimStudioClient:
|
||||
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, stream, selected_outputs)
|
||||
return self.execute_workflow(
|
||||
workflow_id,
|
||||
input,
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
selected_outputs=selected_outputs,
|
||||
async_execution=False
|
||||
)
|
||||
|
||||
def set_api_key(self, api_key: str) -> None:
|
||||
"""
|
||||
@@ -410,7 +413,8 @@ class SimStudioClient:
|
||||
def execute_with_retry(
|
||||
self,
|
||||
workflow_id: str,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
input: Optional[Any] = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
stream: Optional[bool] = None,
|
||||
selected_outputs: Optional[list] = None,
|
||||
@@ -425,7 +429,7 @@ class SimStudioClient:
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input_data: Input data to pass to the workflow (can include file-like objects)
|
||||
input: Input data to pass to the workflow (can include file-like objects)
|
||||
timeout: Timeout in seconds
|
||||
stream: Enable streaming responses
|
||||
selected_outputs: Block outputs to stream
|
||||
@@ -448,11 +452,11 @@ class SimStudioClient:
|
||||
try:
|
||||
return self.execute_workflow(
|
||||
workflow_id,
|
||||
input_data,
|
||||
timeout,
|
||||
stream,
|
||||
selected_outputs,
|
||||
async_execution
|
||||
input,
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
selected_outputs=selected_outputs,
|
||||
async_execution=async_execution
|
||||
)
|
||||
except SimStudioError as e:
|
||||
if e.code != 'RATE_LIMIT_EXCEEDED':
|
||||
|
||||
@@ -91,11 +91,9 @@ def test_context_manager(mock_close):
|
||||
"""Test SimStudioClient as context manager."""
|
||||
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()
|
||||
|
||||
|
||||
# Tests for async execution
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_async_execution_returns_task_id(mock_post):
|
||||
"""Test async execution returns AsyncExecutionResult."""
|
||||
@@ -115,7 +113,7 @@ def test_async_execution_returns_task_id(mock_post):
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_workflow(
|
||||
"workflow-id",
|
||||
input_data={"message": "Hello"},
|
||||
{"message": "Hello"},
|
||||
async_execution=True
|
||||
)
|
||||
|
||||
@@ -124,7 +122,6 @@ def test_async_execution_returns_task_id(mock_post):
|
||||
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"
|
||||
|
||||
@@ -146,7 +143,7 @@ def test_sync_execution_returns_result(mock_post):
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_workflow(
|
||||
"workflow-id",
|
||||
input_data={"message": "Hello"},
|
||||
{"message": "Hello"},
|
||||
async_execution=False
|
||||
)
|
||||
|
||||
@@ -166,13 +163,12 @@ def test_async_header_not_set_when_false(mock_post):
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", input_data={"message": "Hello"})
|
||||
client.execute_workflow("workflow-id", {"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."""
|
||||
@@ -222,7 +218,6 @@ def test_get_job_status_not_found(mock_get):
|
||||
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):
|
||||
@@ -238,7 +233,7 @@ def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_with_retry("workflow-id", input_data={"message": "test"})
|
||||
result = client.execute_with_retry("workflow-id", {"message": "test"})
|
||||
|
||||
assert result.success is True
|
||||
assert mock_post.call_count == 1
|
||||
@@ -278,7 +273,7 @@ def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post):
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_with_retry(
|
||||
"workflow-id",
|
||||
input_data={"message": "test"},
|
||||
{"message": "test"},
|
||||
max_retries=3,
|
||||
initial_delay=0.01
|
||||
)
|
||||
@@ -307,7 +302,7 @@ def test_execute_with_retry_max_retries_exceeded(mock_sleep, mock_post):
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.execute_with_retry(
|
||||
"workflow-id",
|
||||
input_data={"message": "test"},
|
||||
{"message": "test"},
|
||||
max_retries=2,
|
||||
initial_delay=0.01
|
||||
)
|
||||
@@ -333,13 +328,12 @@ def test_execute_with_retry_no_retry_on_other_errors(mock_post):
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.execute_with_retry("workflow-id", input_data={"message": "test"})
|
||||
client.execute_with_retry("workflow-id", {"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")
|
||||
@@ -362,7 +356,7 @@ def test_get_rate_limit_info_after_api_call(mock_post):
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", input_data={})
|
||||
client.execute_workflow("workflow-id", {})
|
||||
|
||||
info = client.get_rate_limit_info()
|
||||
assert info is not None
|
||||
@@ -371,7 +365,6 @@ def test_get_rate_limit_info_after_api_call(mock_post):
|
||||
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."""
|
||||
@@ -435,7 +428,6 @@ def test_get_usage_limits_unauthorized(mock_get):
|
||||
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."""
|
||||
@@ -449,7 +441,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow(
|
||||
"workflow-id",
|
||||
input_data={"message": "test"},
|
||||
{"message": "test"},
|
||||
stream=True,
|
||||
selected_outputs=["agent1.content", "agent2.content"]
|
||||
)
|
||||
@@ -459,4 +451,85 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
|
||||
|
||||
assert request_body["message"] == "test"
|
||||
assert request_body["stream"] is True
|
||||
assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]
|
||||
assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]
|
||||
|
||||
|
||||
# Tests for primitive and list inputs
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_string_input(mock_post):
|
||||
"""Test execution with primitive string input wraps in input field."""
|
||||
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", "NVDA")
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["input"] == "NVDA"
|
||||
assert "0" not in request_body # Should not spread string characters
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_number_input(mock_post):
|
||||
"""Test execution with primitive number input wraps in input field."""
|
||||
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", 42)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["input"] == 42
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_list_input(mock_post):
|
||||
"""Test execution with list input wraps in input field."""
|
||||
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", ["NVDA", "AAPL", "GOOG"])
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["input"] == ["NVDA", "AAPL", "GOOG"]
|
||||
assert "0" not in request_body # Should not spread list
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_dict_input_spreads_at_root(mock_post):
|
||||
"""Test execution with dict input spreads at root level."""
|
||||
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", {"ticker": "NVDA", "quantity": 100})
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["ticker"] == "NVDA"
|
||||
assert request_body["quantity"] == 100
|
||||
assert "input" not in request_body # Should not wrap in input field
|
||||
@@ -47,24 +47,35 @@ new SimStudioClient(config: SimStudioConfig)
|
||||
|
||||
#### Methods
|
||||
|
||||
##### executeWorkflow(workflowId, options?)
|
||||
##### executeWorkflow(workflowId, input?, options?)
|
||||
|
||||
Execute a workflow with optional input data.
|
||||
|
||||
```typescript
|
||||
// With object input (spread at root level of request body)
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: { message: 'Hello, world!' },
|
||||
timeout: 30000 // 30 seconds
|
||||
message: 'Hello, world!'
|
||||
});
|
||||
|
||||
// With primitive input (wrapped as { input: value })
|
||||
const result = await client.executeWorkflow('workflow-id', 'NVDA');
|
||||
|
||||
// With options
|
||||
const result = await client.executeWorkflow('workflow-id', { message: 'Hello' }, {
|
||||
timeout: 60000
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflowId` (string): The ID of the workflow to execute
|
||||
- `input` (any, optional): Input data to pass to the workflow. Objects are spread at the root level, primitives/arrays are wrapped in `{ input: value }`. File objects are automatically converted to base64.
|
||||
- `options` (ExecutionOptions, optional):
|
||||
- `input` (any): Input data to pass to the workflow. File objects are automatically converted to base64.
|
||||
- `timeout` (number): Timeout in milliseconds (default: 30000)
|
||||
- `stream` (boolean): Enable streaming responses
|
||||
- `selectedOutputs` (string[]): Block outputs to stream (e.g., `["agent1.content"]`)
|
||||
- `async` (boolean): Execute asynchronously and return execution ID
|
||||
|
||||
**Returns:** `Promise<WorkflowExecutionResult>`
|
||||
**Returns:** `Promise<WorkflowExecutionResult | AsyncExecutionResult>`
|
||||
|
||||
##### getWorkflowStatus(workflowId)
|
||||
|
||||
@@ -96,25 +107,89 @@ if (isReady) {
|
||||
|
||||
**Returns:** `Promise<boolean>`
|
||||
|
||||
##### executeWorkflowSync(workflowId, options?)
|
||||
##### executeWorkflowSync(workflowId, input?, options?)
|
||||
|
||||
Execute a workflow and poll for completion (useful for long-running workflows).
|
||||
|
||||
```typescript
|
||||
const result = await client.executeWorkflowSync('workflow-id', {
|
||||
input: { data: 'some input' },
|
||||
const result = await client.executeWorkflowSync('workflow-id', { data: 'some input' }, {
|
||||
timeout: 60000
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflowId` (string): The ID of the workflow to execute
|
||||
- `input` (any, optional): Input data to pass to the workflow
|
||||
- `options` (ExecutionOptions, optional):
|
||||
- `input` (any): Input data to pass to the workflow
|
||||
- `timeout` (number): Timeout for the initial request in milliseconds
|
||||
|
||||
**Returns:** `Promise<WorkflowExecutionResult>`
|
||||
|
||||
##### getJobStatus(taskId)
|
||||
|
||||
Get the status of an async job.
|
||||
|
||||
```typescript
|
||||
const status = await client.getJobStatus('task-id-from-async-execution');
|
||||
console.log('Job status:', status);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `taskId` (string): The task ID returned from async execution
|
||||
|
||||
**Returns:** `Promise<any>`
|
||||
|
||||
##### executeWithRetry(workflowId, input?, options?, retryOptions?)
|
||||
|
||||
Execute a workflow with automatic retry on rate limit errors.
|
||||
|
||||
```typescript
|
||||
const result = await client.executeWithRetry('workflow-id', { message: 'Hello' }, {
|
||||
timeout: 30000
|
||||
}, {
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
backoffMultiplier: 2
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflowId` (string): The ID of the workflow to execute
|
||||
- `input` (any, optional): Input data to pass to the workflow
|
||||
- `options` (ExecutionOptions, optional): Execution options
|
||||
- `retryOptions` (RetryOptions, optional):
|
||||
- `maxRetries` (number): Maximum retry attempts (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 | AsyncExecutionResult>`
|
||||
|
||||
##### getRateLimitInfo()
|
||||
|
||||
Get current rate limit information from the last API response.
|
||||
|
||||
```typescript
|
||||
const rateInfo = client.getRateLimitInfo();
|
||||
if (rateInfo) {
|
||||
console.log('Remaining requests:', rateInfo.remaining);
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `RateLimitInfo | null`
|
||||
|
||||
##### getUsageLimits()
|
||||
|
||||
Get current usage limits and quota information.
|
||||
|
||||
```typescript
|
||||
const limits = await client.getUsageLimits();
|
||||
console.log('Current usage:', limits.usage);
|
||||
```
|
||||
|
||||
**Returns:** `Promise<UsageLimits>`
|
||||
|
||||
##### setApiKey(apiKey)
|
||||
|
||||
Update the API key.
|
||||
@@ -170,6 +245,81 @@ class SimStudioError extends Error {
|
||||
}
|
||||
```
|
||||
|
||||
### AsyncExecutionResult
|
||||
|
||||
```typescript
|
||||
interface AsyncExecutionResult {
|
||||
success: boolean;
|
||||
taskId: string;
|
||||
status: 'queued';
|
||||
createdAt: string;
|
||||
links: {
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 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;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ExecutionOptions
|
||||
|
||||
```typescript
|
||||
interface ExecutionOptions {
|
||||
timeout?: number;
|
||||
stream?: boolean;
|
||||
selectedOutputs?: string[];
|
||||
async?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### RetryOptions
|
||||
|
||||
```typescript
|
||||
interface RetryOptions {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffMultiplier?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Workflow Execution
|
||||
@@ -191,10 +341,8 @@ async function runWorkflow() {
|
||||
|
||||
// Execute the workflow
|
||||
const result = await client.executeWorkflow('my-workflow-id', {
|
||||
input: {
|
||||
message: 'Process this data',
|
||||
userId: '12345'
|
||||
}
|
||||
message: 'Process this data',
|
||||
userId: '12345'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -298,22 +446,18 @@ const file = new File([fileBuffer], 'document.pdf', { type: 'application/pdf' })
|
||||
|
||||
// Include files under the field name from your API trigger's input format
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: {
|
||||
documents: [file], // Field name must match your API trigger's file input field
|
||||
instructions: 'Process this document'
|
||||
}
|
||||
documents: [file], // Field name must match your API trigger's file input field
|
||||
instructions: 'Process this document'
|
||||
});
|
||||
|
||||
// Browser: From file input
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
const inputEl = event.target as HTMLInputElement;
|
||||
const files = Array.from(inputEl.files || []);
|
||||
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: {
|
||||
attachments: files, // Field name must match your API trigger's file input field
|
||||
query: 'Analyze these files'
|
||||
}
|
||||
attachments: files, // Field name must match your API trigger's file input field
|
||||
query: 'Analyze these files'
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simstudio-ts-sdk",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "Sim SDK - Execute workflows programmatically",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -119,10 +119,11 @@ describe('SimStudioClient', () => {
|
||||
}
|
||||
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: { message: 'Hello' },
|
||||
async: true,
|
||||
})
|
||||
const result = await client.executeWorkflow(
|
||||
'workflow-id',
|
||||
{ message: 'Hello' },
|
||||
{ async: true }
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty('taskId', 'task-123')
|
||||
expect(result).toHaveProperty('status', 'queued')
|
||||
@@ -152,10 +153,11 @@ describe('SimStudioClient', () => {
|
||||
}
|
||||
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: { message: 'Hello' },
|
||||
async: false,
|
||||
})
|
||||
const result = await client.executeWorkflow(
|
||||
'workflow-id',
|
||||
{ message: 'Hello' },
|
||||
{ async: false }
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty('success', true)
|
||||
expect(result).toHaveProperty('output')
|
||||
@@ -177,9 +179,7 @@ describe('SimStudioClient', () => {
|
||||
}
|
||||
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
||||
|
||||
await client.executeWorkflow('workflow-id', {
|
||||
input: { message: 'Hello' },
|
||||
})
|
||||
await client.executeWorkflow('workflow-id', { message: 'Hello' })
|
||||
|
||||
const calls = vi.mocked(fetch.default).mock.calls
|
||||
expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode')
|
||||
@@ -256,9 +256,7 @@ describe('SimStudioClient', () => {
|
||||
}
|
||||
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const result = await client.executeWithRetry('workflow-id', {
|
||||
input: { message: 'test' },
|
||||
})
|
||||
const result = await client.executeWithRetry('workflow-id', { message: 'test' })
|
||||
|
||||
expect(result).toHaveProperty('success', true)
|
||||
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1)
|
||||
@@ -305,7 +303,8 @@ describe('SimStudioClient', () => {
|
||||
|
||||
const result = await client.executeWithRetry(
|
||||
'workflow-id',
|
||||
{ input: { message: 'test' } },
|
||||
{ message: 'test' },
|
||||
{},
|
||||
{ maxRetries: 3, initialDelay: 10 }
|
||||
)
|
||||
|
||||
@@ -336,7 +335,8 @@ describe('SimStudioClient', () => {
|
||||
await expect(
|
||||
client.executeWithRetry(
|
||||
'workflow-id',
|
||||
{ input: { message: 'test' } },
|
||||
{ message: 'test' },
|
||||
{},
|
||||
{ maxRetries: 2, initialDelay: 10 }
|
||||
)
|
||||
).rejects.toThrow('Rate limit exceeded')
|
||||
@@ -361,9 +361,9 @@ describe('SimStudioClient', () => {
|
||||
|
||||
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
||||
|
||||
await expect(
|
||||
client.executeWithRetry('workflow-id', { input: { message: 'test' } })
|
||||
).rejects.toThrow('Server error')
|
||||
await expect(client.executeWithRetry('workflow-id', { message: 'test' })).rejects.toThrow(
|
||||
'Server error'
|
||||
)
|
||||
|
||||
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) // No retries
|
||||
})
|
||||
@@ -393,7 +393,7 @@ describe('SimStudioClient', () => {
|
||||
|
||||
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
||||
|
||||
await client.executeWorkflow('workflow-id', { input: {} })
|
||||
await client.executeWorkflow('workflow-id', {})
|
||||
|
||||
const info = client.getRateLimitInfo()
|
||||
expect(info).not.toBeNull()
|
||||
@@ -490,11 +490,11 @@ describe('SimStudioClient', () => {
|
||||
|
||||
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
||||
|
||||
await client.executeWorkflow('workflow-id', {
|
||||
input: { message: 'test' },
|
||||
stream: true,
|
||||
selectedOutputs: ['agent1.content', 'agent2.content'],
|
||||
})
|
||||
await client.executeWorkflow(
|
||||
'workflow-id',
|
||||
{ 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)
|
||||
@@ -505,6 +505,134 @@ describe('SimStudioClient', () => {
|
||||
expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeWorkflow - primitive and array inputs', () => {
|
||||
it('should wrap primitive string input in input field', 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', 'NVDA')
|
||||
|
||||
const calls = vi.mocked(fetch.default).mock.calls
|
||||
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
||||
|
||||
expect(requestBody).toHaveProperty('input', 'NVDA')
|
||||
expect(requestBody).not.toHaveProperty('0') // Should not spread string characters
|
||||
})
|
||||
|
||||
it('should wrap primitive number input in input field', 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', 42)
|
||||
|
||||
const calls = vi.mocked(fetch.default).mock.calls
|
||||
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
||||
|
||||
expect(requestBody).toHaveProperty('input', 42)
|
||||
})
|
||||
|
||||
it('should wrap array input in input field', 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', ['NVDA', 'AAPL', 'GOOG'])
|
||||
|
||||
const calls = vi.mocked(fetch.default).mock.calls
|
||||
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
||||
|
||||
expect(requestBody).toHaveProperty('input')
|
||||
expect(requestBody.input).toEqual(['NVDA', 'AAPL', 'GOOG'])
|
||||
expect(requestBody).not.toHaveProperty('0') // Should not spread array
|
||||
})
|
||||
|
||||
it('should spread object input at root level', 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', { ticker: 'NVDA', quantity: 100 })
|
||||
|
||||
const calls = vi.mocked(fetch.default).mock.calls
|
||||
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
||||
|
||||
expect(requestBody).toHaveProperty('ticker', 'NVDA')
|
||||
expect(requestBody).toHaveProperty('quantity', 100)
|
||||
expect(requestBody).not.toHaveProperty('input') // Should not wrap in input field
|
||||
})
|
||||
|
||||
it('should handle null input as no input (empty 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', null)
|
||||
|
||||
const calls = vi.mocked(fetch.default).mock.calls
|
||||
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
||||
|
||||
// null treated as "no input" - sends empty body (consistent with Python SDK)
|
||||
expect(requestBody).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SimStudioError', () => {
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface WorkflowStatus {
|
||||
}
|
||||
|
||||
export interface ExecutionOptions {
|
||||
input?: any
|
||||
timeout?: number
|
||||
stream?: boolean
|
||||
selectedOutputs?: string[]
|
||||
@@ -117,10 +116,6 @@ export class SimStudioClient {
|
||||
this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai')
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow with optional input data
|
||||
* If async is true, returns immediately with a task ID
|
||||
*/
|
||||
/**
|
||||
* Convert File objects in input to API format (base64)
|
||||
* Recursively processes nested objects and arrays
|
||||
@@ -170,20 +165,25 @@ export class SimStudioClient {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow with optional input data
|
||||
* @param workflowId - The ID of the workflow to execute
|
||||
* @param input - Input data to pass to the workflow (object, primitive, or array)
|
||||
* @param options - Execution options (timeout, stream, async, etc.)
|
||||
*/
|
||||
async executeWorkflow(
|
||||
workflowId: string,
|
||||
input?: any,
|
||||
options: ExecutionOptions = {}
|
||||
): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
|
||||
const url = `${this.baseUrl}/api/workflows/${workflowId}/execute`
|
||||
const { input, timeout = 30000, stream, selectedOutputs, async } = options
|
||||
const { timeout = 30000, stream, selectedOutputs, async } = options
|
||||
|
||||
try {
|
||||
// Create a timeout promise
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('TIMEOUT')), timeout)
|
||||
})
|
||||
|
||||
// Build headers - async execution uses X-Execution-Mode header
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiKey,
|
||||
@@ -192,10 +192,15 @@ export class SimStudioClient {
|
||||
headers['X-Execution-Mode'] = 'async'
|
||||
}
|
||||
|
||||
// Build JSON body - spread input at root level, then add API control parameters
|
||||
let jsonBody: any = input !== undefined ? { ...input } : {}
|
||||
let jsonBody: any = {}
|
||||
if (input !== undefined && input !== null) {
|
||||
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
|
||||
jsonBody = { ...input }
|
||||
} else {
|
||||
jsonBody = { input }
|
||||
}
|
||||
}
|
||||
|
||||
// Convert any File objects in the input to base64 format
|
||||
jsonBody = await this.convertFilesToBase64(jsonBody)
|
||||
|
||||
if (stream !== undefined) {
|
||||
@@ -213,10 +218,8 @@ export class SimStudioClient {
|
||||
|
||||
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(
|
||||
@@ -285,15 +288,18 @@ export class SimStudioClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow and poll for completion (useful for long-running workflows)
|
||||
* Execute a workflow synchronously (ensures non-async mode)
|
||||
* @param workflowId - The ID of the workflow to execute
|
||||
* @param input - Input data to pass to the workflow
|
||||
* @param options - Execution options (timeout, stream, etc.)
|
||||
*/
|
||||
async executeWorkflowSync(
|
||||
workflowId: string,
|
||||
input?: any,
|
||||
options: ExecutionOptions = {}
|
||||
): Promise<WorkflowExecutionResult> {
|
||||
// Ensure sync mode by explicitly setting async to false
|
||||
const syncOptions = { ...options, async: false }
|
||||
return this.executeWorkflow(workflowId, syncOptions) as Promise<WorkflowExecutionResult>
|
||||
return this.executeWorkflow(workflowId, input, syncOptions) as Promise<WorkflowExecutionResult>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,9 +367,14 @@ export class SimStudioClient {
|
||||
|
||||
/**
|
||||
* Execute workflow with automatic retry on rate limit
|
||||
* @param workflowId - The ID of the workflow to execute
|
||||
* @param input - Input data to pass to the workflow
|
||||
* @param options - Execution options (timeout, stream, async, etc.)
|
||||
* @param retryOptions - Retry configuration (maxRetries, delays, etc.)
|
||||
*/
|
||||
async executeWithRetry(
|
||||
workflowId: string,
|
||||
input?: any,
|
||||
options: ExecutionOptions = {},
|
||||
retryOptions: RetryOptions = {}
|
||||
): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
|
||||
@@ -379,7 +390,7 @@ export class SimStudioClient {
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await this.executeWorkflow(workflowId, options)
|
||||
return await this.executeWorkflow(workflowId, input, options)
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof SimStudioError) || error.code !== 'RATE_LIMIT_EXCEEDED') {
|
||||
throw error
|
||||
@@ -387,23 +398,19 @@ export class SimStudioClient {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -475,5 +482,4 @@ export class SimStudioClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Export types and classes
|
||||
export { SimStudioClient as default }
|
||||
|
||||
@@ -126,7 +126,7 @@ async function fetchGitHubCommitDetails(
|
||||
|
||||
const githubUsername = commit.author?.login || commit.committer?.login || 'unknown'
|
||||
|
||||
let cleanMessage = commit.commit.message.split('\n')[0] // First line only
|
||||
let cleanMessage = commit.commit.message.split('\n')[0]
|
||||
if (prNumber) {
|
||||
cleanMessage = cleanMessage.replace(/\s*\(#\d+\)\s*$/, '')
|
||||
}
|
||||
@@ -226,12 +226,23 @@ async function getCommitsBetweenVersions(
|
||||
function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements' | 'other' {
|
||||
const msgLower = message.toLowerCase()
|
||||
|
||||
if (
|
||||
msgLower.includes('feat') ||
|
||||
msgLower.includes('add') ||
|
||||
msgLower.includes('implement') ||
|
||||
msgLower.includes('new ')
|
||||
) {
|
||||
if (/^feat(\(|:|!)/.test(msgLower)) {
|
||||
return 'features'
|
||||
}
|
||||
|
||||
if (/^fix(\(|:|!)/.test(msgLower)) {
|
||||
return 'fixes'
|
||||
}
|
||||
|
||||
if (/^(improvement|improve|perf|refactor)(\(|:|!)/.test(msgLower)) {
|
||||
return 'improvements'
|
||||
}
|
||||
|
||||
if (/^(chore|docs|style|test|ci|build)(\(|:|!)/.test(msgLower)) {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
if (msgLower.includes('feat') || msgLower.includes('implement') || msgLower.includes('new ')) {
|
||||
return 'features'
|
||||
}
|
||||
|
||||
@@ -242,9 +253,10 @@ function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements
|
||||
if (
|
||||
msgLower.includes('improve') ||
|
||||
msgLower.includes('enhance') ||
|
||||
msgLower.includes('update') ||
|
||||
msgLower.includes('upgrade') ||
|
||||
msgLower.includes('optimization')
|
||||
msgLower.includes('optimization') ||
|
||||
msgLower.includes('add') ||
|
||||
msgLower.includes('update')
|
||||
) {
|
||||
return 'improvements'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user