Compare commits

..

55 Commits

Author SHA1 Message Date
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
80f00479a3 improvement(docs): added images and videos to quick references (#3004)
* improvement(docs): added images and videos to quick references

* moved mp4s to blob, completed quick reference guide
2026-01-25 23:31:40 -08:00
Vikhyath Mondreti
c140e90559 fix(multi-trigger): resolution paths for triggers (#3002)
* fix(multi-trigger): resolution paths for triggers

* fix trigger input format version

* fix output condition logic

* update type guard:

* fix
2026-01-25 23:20:42 -08:00
Vikhyath Mondreti
d83c418111 fix(supabase): storage upload + add basic mode version (#2996)
* fix(supabase): storage upload + add basic mode version

* fix subblock update

* remove redundant check in a2a

* add check consistently for baseline diff
2026-01-25 14:19:30 -08:00
Waleed
be2a9ef0f8 fix(storage): support Azure connection string for presigned URLs (#2997)
* fix(docs): update requirements to be more accurate for deploying the app

* updated kb to support 1536 dimension vectors for models other than text embedding 3 small

* fix(storage): support Azure connection string for presigned URLs

* fix(kb): update test for embedding dimensions parameter

* fix(storage): align credential source ordering for consistency
2026-01-25 13:06:12 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
1bf5ed4586 improvement(docs): add quick reference page and update SDK documentation (#2994)
* docs(sdk): update README to reflect new interface

* improvement(docs): add quick reference page and update SDK documentation

* docs(copilot): update copilot documentation with all features
2026-01-25 02:21:02 -08:00
Waleed
dc0ed842c4 fix(sdk): improve input handling and separate input from options (#2993)
* fix(sdk): improve input handling and separate input from options

* fix(sdk): treat null as no input for consistency with Python SDK
2026-01-25 00:50:09 -08:00
Waleed
1952b196a0 fix(releases): improve commit categorization and ci security (#2992)
* fix(releases): improve commit categorization and CI security

* fix(releases): remove redundant update check
2026-01-24 22:33:04 -08:00
Vikhyath Mondreti
fa03d4d818 fix(copilot): canonical modes should be constructed on edit (#2989)
* fix(copilot): canonical modes should be constructed

* reuse canonicalIndex

* fix insert into subflow case:
2026-01-24 22:13:57 -08:00
Waleed
e14cebeec5 fix(context-menu): preserve selection when right-clicking selected block (#2991)
* fix(context-menu): preserve selection when right-clicking selected block

* added tsdoc
2026-01-24 22:05:27 -08:00
Waleed
404d8c006e fix(tooltip): add tooltip to canonical toggle button (#2990)
* fix(tooltip): add tooltip to canonical toggle button

* ack comments
2026-01-24 21:58:09 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
ac91d78834 fix(anthropic): use anthropic sdk to transform malformed response schemas to anthropic format (#2988)
* fix(anthropic): use anthropic sdk to transform malformed response schemas to anthropic format

* copy internal transformJSONSchema from anthropic

* remove dep update

* use built-in func from anthropic
2026-01-24 20:31:07 -08:00
Waleed
6f0a093869 fix(llm): update router and llm_chat tool to call providers routes (#2986)
* fix(llm): update router and llm_chat tool to call providers routes

* updated failing tests
2026-01-24 18:50:37 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
bcf6dc8828 fix(variables): boolean type support and input improvements (#2981)
* fix(variables): boolean type support and input improvements

* fix formatting
2026-01-24 13:52:09 -08:00
Vikhyath Mondreti
841cb638fb fix(edge-validation): race condition on collaborative add (#2980) 2026-01-24 13:19:52 -08:00
Emir Karabeg
c7db48e3a2 fix(landing): ui (#2979) 2026-01-24 13:04:06 -08:00
Siddharth Ganesan
4d844651c2 fix(integrations): hide from tool bar (#2544) 2026-01-24 12:45:14 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
99 changed files with 2303 additions and 962 deletions

View File

@@ -44,7 +44,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 4G memory: 1G
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio - DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio

View File

@@ -27,10 +27,11 @@ jobs:
steps: steps:
- name: Extract version from commit message - name: Extract version from commit message
id: extract id: extract
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: | run: |
COMMIT_MSG="${{ github.event.head_commit.message }}"
# Only tag versions on main branch # 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]}" VERSION="${BASH_REMATCH[1]}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "is_release=true" >> $GITHUB_OUTPUT echo "is_release=true" >> $GITHUB_OUTPUT

View File

@@ -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 */ /* Desktop only: Apply custom navbar offset, sidebar width and margin offsets */
/* On mobile, let fumadocs handle the layout natively */ /* On mobile, let fumadocs handle the layout natively */
@media (min-width: 1024px) { @media (min-width: 1024px) {

View File

@@ -0,0 +1,40 @@
'use client'
import { getAssetUrl } from '@/lib/utils'
interface ActionImageProps {
src: string
alt: string
}
interface ActionVideoProps {
src: string
alt: string
}
export function ActionImage({ src, alt }: ActionImageProps) {
const resolvedSrc = getAssetUrl(src.startsWith('/') ? src.slice(1) : src)
return (
<img
src={resolvedSrc}
alt={alt}
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
/>
)
}
export function ActionVideo({ src, alt }: ActionVideoProps) {
const resolvedSrc = getAssetUrl(src.startsWith('/') ? src.slice(1) : src)
return (
<video
src={resolvedSrc}
autoPlay
loop
muted
playsInline
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
/>
)
}

View File

@@ -10,12 +10,20 @@ Stellen Sie Sim auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes berei
## Anforderungen ## Anforderungen
| Ressource | Minimum | Empfohlen | | Ressource | Klein | Standard | Produktion |
|----------|---------|-------------| |----------|-------|----------|------------|
| CPU | 2 Kerne | 4+ Kerne | | CPU | 2 Kerne | 4 Kerne | 8+ Kerne |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| Speicher | 20 GB SSD | 50+ GB SSD | | Speicher | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | Neueste Version | | Docker | 20.10+ | 20.10+ | Neueste Version |
**Klein**: Entwicklung, Tests, Einzelnutzer (1-5 Nutzer)
**Standard**: Teams (5-50 Nutzer), moderate Arbeitslasten
**Produktion**: Große Teams (50+ Nutzer), Hochverfügbarkeit, intensive Workflow-Ausführung
<Callout type="info">
Die Ressourcenanforderungen werden durch Workflow-Ausführung (isolated-vm Sandboxing), Dateiverarbeitung (In-Memory-Dokumentenparsing) und Vektoroperationen (pgvector) bestimmt. Arbeitsspeicher ist typischerweise der limitierende Faktor, nicht CPU. Produktionsdaten zeigen, dass die Hauptanwendung durchschnittlich 4-8 GB und bei hoher Last bis zu 12 GB benötigt.
</Callout>
## Schnellstart ## Schnellstart

View File

@@ -5,45 +5,25 @@ title: Copilot
import { Callout } from 'fumadocs-ui/components/callout' import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card' import { Card, Cards } from 'fumadocs-ui/components/card'
import { Image } from '@/components/ui/image' 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 - **Explain**: Answer questions about Sim and your current workflow
- **Guide**: Suggest edits and best practices - **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"> <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 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> </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 ## Modes
Switch between modes using the mode selector at the bottom of the input area.
<Cards> <Cards>
<Card <Card
title={ title={
@@ -60,113 +40,153 @@ This contextual information helps Copilot provide more accurate and relevant ass
<Card <Card
title={ title={
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" /> <Hammer className="h-4 w-4 text-muted-foreground" />
Agent Build
</span> </span>
} }
> >
<div className="m-0 text-sm"> <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> </div>
</Card> </Card>
</Cards> </Cards>
<div className="flex justify-center"> ## Models
<Image
src="/static/copilot/copilot-mode.png"
alt="Copilot mode selection interface"
width={600}
height={400}
className="my-6"
/>
</div>
## Depth Levels Select your preferred AI model using the model selector at the bottom right of the input area.
<Cards> **Available Models:**
<Card - Claude 4.5 Opus, Sonnet (default), Haiku
title={ - GPT 5.2 Codex, Pro
<span className="inline-flex items-center gap-2"> - Gemini 3 Pro
<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>
### 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 Use the `@` symbol to reference resources and give Copilot more context:
src="/static/copilot/copilot-models.png"
alt="Copilot mode selection showing Advanced mode with MAX toggle"
width={600}
height={300}
/>
The interface allows you to: | Reference | Description |
- **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 | **Chats** | Previous copilot conversations |
- **See mode descriptions**: Understand what each mode is optimized for | **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**) **Web Commands:**
- **Output tokens**: billed at **1.5×** the provider's base output rate
```javascript | Command | Description |
copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000 |---------|-------------|
``` | `/search` | Search the web |
| `/read` | Read a specific URL |
| `/scrape` | Scrape web page content |
| `/crawl` | Crawl multiple pages |
| Component | Rate Applied | Type `/` in the input field to see available commands.
|----------|----------------------|
| Input | inputPrice |
| Output | outputPrice × 1.5 |
<Callout type="warning"> ## Chat Management
Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing.
</Callout> ### 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"> <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> </Callout>

View File

@@ -34,6 +34,8 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls
| `Mod` + `V` | Paste blocks | | `Mod` + `V` | Paste blocks |
| `Delete` or `Backspace` | Delete selected blocks or edges | | `Delete` or `Backspace` | Delete selected blocks or edges |
| `Shift` + `L` | Auto-layout canvas | | `Shift` + `L` | Auto-layout canvas |
| `Mod` + `Shift` + `F` | Fit to view |
| `Mod` + `Shift` + `Enter` | Accept Copilot changes |
## Panel Navigation ## Panel Navigation

View File

@@ -3,6 +3,7 @@
"pages": [ "pages": [
"./introduction/index", "./introduction/index",
"./getting-started/index", "./getting-started/index",
"./quick-reference/index",
"triggers", "triggers",
"blocks", "blocks",
"tools", "tools",

View File

@@ -0,0 +1,375 @@
---
title: Quick Reference
description: Essential actions for navigating and using the Sim workflow editor
---
import { Callout } from 'fumadocs-ui/components/callout'
import { ActionImage, ActionVideo } from '@/components/ui/action-media'
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
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Create a workspace</td>
<td>Click workspace dropdown → **New Workspace**</td>
<td><ActionVideo src="/static/quick-reference/create-workspace.mp4" alt="Create workspace" /></td>
</tr>
<tr>
<td>Switch workspaces</td>
<td>Click workspace dropdown → Select workspace</td>
<td><ActionVideo src="/static/quick-reference/switch-workspace.mp4" alt="Switch workspaces" /></td>
</tr>
<tr>
<td>Invite team members</td>
<td>Sidebar → **Invite**</td>
<td><ActionVideo src="/static/quick-reference/invite.mp4" alt="Invite team members" /></td>
</tr>
<tr>
<td>Rename a workspace</td>
<td>Right-click workspace → **Rename**</td>
<td rowSpan={4}><ActionImage src="/static/quick-reference/workspace-context-menu.png" alt="Workspace context menu" /></td>
</tr>
<tr>
<td>Duplicate a workspace</td>
<td>Right-click workspace → **Duplicate**</td>
</tr>
<tr>
<td>Export a workspace</td>
<td>Right-click workspace → **Export**</td>
</tr>
<tr>
<td>Delete a workspace</td>
<td>Right-click workspace → **Delete**</td>
</tr>
</tbody>
</table>
## Workflows
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Create a workflow</td>
<td>Click **+** button in sidebar</td>
<td><ActionImage src="/static/quick-reference/create-workflow.png" alt="Create workflow" /></td>
</tr>
<tr>
<td>Reorder / move workflows</td>
<td>Drag workflow up/down or onto a folder</td>
<td><ActionVideo src="/static/quick-reference/reordering.mp4" alt="Reorder workflows" /></td>
</tr>
<tr>
<td>Import a workflow</td>
<td>Click import button in sidebar → Select file</td>
<td><ActionImage src="/static/quick-reference/import-workflow.png" alt="Import workflow" /></td>
</tr>
<tr>
<td>Multi-select workflows</td>
<td>`Mod+Click` or `Shift+Click` workflows in sidebar</td>
<td><ActionVideo src="/static/quick-reference/multiselect.mp4" alt="Multi-select workflows" /></td>
</tr>
<tr>
<td>Open in new tab</td>
<td>Right-click workflow → **Open in New Tab**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/workflow-context-menu.png" alt="Workflow context menu" /></td>
</tr>
<tr>
<td>Rename a workflow</td>
<td>Right-click workflow → **Rename**</td>
</tr>
<tr>
<td>Assign workflow color</td>
<td>Right-click workflow → **Change Color**</td>
</tr>
<tr>
<td>Duplicate a workflow</td>
<td>Right-click workflow → **Duplicate**</td>
</tr>
<tr>
<td>Export a workflow</td>
<td>Right-click workflow → **Export**</td>
</tr>
<tr>
<td>Delete a workflow</td>
<td>Right-click workflow → **Delete**</td>
</tr>
<tr>
<td>Rename a folder</td>
<td>Right-click folder → **Rename**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/folder-context-menu.png" alt="Folder context menu" /></td>
</tr>
<tr>
<td>Create workflow in folder</td>
<td>Right-click folder → **Create workflow**</td>
</tr>
<tr>
<td>Create folder in folder</td>
<td>Right-click folder → **Create folder**</td>
</tr>
<tr>
<td>Duplicate a folder</td>
<td>Right-click folder → **Duplicate**</td>
</tr>
<tr>
<td>Export a folder</td>
<td>Right-click folder → **Export**</td>
</tr>
<tr>
<td>Delete a folder</td>
<td>Right-click folder → **Delete**</td>
</tr>
</tbody>
</table>
## Blocks
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Add a block</td>
<td>Drag from Toolbar panel, or right-click canvas → **Add Block**</td>
<td><ActionVideo src="/static/quick-reference/add-block.mp4" alt="Add a block" /></td>
</tr>
<tr>
<td>Multi-select blocks</td>
<td>`Mod+Click` additional blocks, or shift-drag to draw selection box</td>
<td><ActionVideo src="/static/quick-reference/multiselect-blocks.mp4" alt="Multi-select blocks" /></td>
</tr>
<tr>
<td>Copy blocks</td>
<td>`Mod+C` with blocks selected</td>
<td rowSpan={2}><ActionVideo src="/static/quick-reference/copy-paste.mp4" alt="Copy and paste blocks" /></td>
</tr>
<tr>
<td>Paste blocks</td>
<td>`Mod+V` to paste copied blocks</td>
</tr>
<tr>
<td>Duplicate blocks</td>
<td>Right-click → **Duplicate**</td>
<td><ActionVideo src="/static/quick-reference/duplicate-block.mp4" alt="Duplicate blocks" /></td>
</tr>
<tr>
<td>Delete blocks</td>
<td>`Delete` or `Backspace` key, or right-click → **Delete**</td>
<td><ActionImage src="/static/quick-reference/delete-block.png" alt="Delete block" /></td>
</tr>
<tr>
<td>Rename a block</td>
<td>Click block name in header, or edit in the Editor panel</td>
<td><ActionVideo src="/static/quick-reference/rename-block.mp4" alt="Rename a block" /></td>
</tr>
<tr>
<td>Enable/Disable a block</td>
<td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr>
<tr>
<td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td>
<td><ActionVideo src="/static/quick-reference/toggle-handles.mp4" alt="Toggle handle orientation" /></td>
</tr>
<tr>
<td>Configure a block</td>
<td>Select block → use Editor panel on right</td>
<td><ActionVideo src="/static/quick-reference/configure-block.mp4" alt="Configure a block" /></td>
</tr>
</tbody>
</table>
## Connections
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Create a connection</td>
<td>Drag from output handle to input handle</td>
<td><ActionVideo src="/static/quick-reference/connect-blocks.mp4" alt="Connect blocks" /></td>
</tr>
<tr>
<td>Delete a connection</td>
<td>Click edge to select → `Delete` key</td>
<td><ActionVideo src="/static/quick-reference/delete-connection.mp4" alt="Delete connection" /></td>
</tr>
<tr>
<td>Use output in another block</td>
<td>Drag connection tag into input field</td>
<td><ActionVideo src="/static/quick-reference/connection-tag.mp4" alt="Use connection tag" /></td>
</tr>
</tbody>
</table>
## Panels & Views
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Search toolbar</td>
<td>`Mod+F`</td>
<td><ActionVideo src="/static/quick-reference/search-toolbar.mp4" alt="Search toolbar" /></td>
</tr>
<tr>
<td>Search everything</td>
<td>`Mod+K`</td>
<td><ActionImage src="/static/quick-reference/search-everything.png" alt="Search everything" /></td>
</tr>
<tr>
<td>Toggle manual mode</td>
<td>Click toggle button to switch between manual and selector</td>
<td><ActionImage src="/static/quick-reference/toggle-manual-mode.png" alt="Toggle manual mode" /></td>
</tr>
<tr>
<td>Collapse/expand sidebar</td>
<td>Click collapse button on sidebar</td>
<td><ActionVideo src="/static/quick-reference/collapse-sidebar.mp4" alt="Collapse sidebar" /></td>
</tr>
</tbody>
</table>
## Running & Testing
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Run workflow</td>
<td>Click Run Workflow button or `Mod+Enter`</td>
<td><ActionImage src="/static/quick-reference/run-workflow.png" alt="Run workflow" /></td>
</tr>
<tr>
<td>Stop workflow</td>
<td>Click Stop button or `Mod+Enter` while running</td>
<td><ActionImage src="/static/quick-reference/stop-workflow.png" alt="Stop workflow" /></td>
</tr>
<tr>
<td>Test with chat</td>
<td>Use Chat panel on the right side</td>
<td><ActionImage src="/static/quick-reference/test-chat.png" alt="Test with chat" /></td>
</tr>
<tr>
<td>Select output to view</td>
<td>Click dropdown in Chat panel → Select block output</td>
<td><ActionImage src="/static/quick-reference/output-select.png" alt="Select output to view" /></td>
</tr>
<tr>
<td>Clear chat history</td>
<td>Click clear button in Chat panel</td>
<td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td>
</tr>
<tr>
<td>View execution logs</td>
<td>Open terminal panel at bottom, or `Mod+L`</td>
<td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td>
</tr>
<tr>
<td>Filter logs by block or status</td>
<td>Click block filter in terminal or right-click log entry → **Filter by Block** or **Filter by Status**</td>
<td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td>
</tr>
<tr>
<td>Search logs</td>
<td>Use search field in terminal or right-click log entry → **Search**</td>
<td><ActionImage src="/static/quick-reference/terminal-search.png" alt="Search logs" /></td>
</tr>
<tr>
<td>Copy log entry</td>
<td>Clipboard Icon or Right-click log entry → **Copy**</td>
<td><ActionImage src="/static/quick-reference/copy-log.png" alt="Copy log entry" /></td>
</tr>
<tr>
<td>Clear terminal</td>
<td>Trash icon or `Mod+D`</td>
<td><ActionImage src="/static/quick-reference/clear-terminal.png" alt="Clear terminal" /></td>
</tr>
</tbody>
</table>
## Deployment
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Deploy a workflow</td>
<td>Click **Deploy** button in panel</td>
<td><ActionImage src="/static/quick-reference/deploy.png" alt="Deploy workflow" /></td>
</tr>
<tr>
<td>Update deployment</td>
<td>Click **Update** when changes are detected</td>
<td><ActionImage src="/static/quick-reference/update-deployment.png" alt="Update deployment" /></td>
</tr>
<tr>
<td>View deployment status</td>
<td>Check status indicator (Live/Update/Deploy) in Deploy tab</td>
<td><ActionImage src="/static/quick-reference/view-deployment.png" alt="View deployment status" /></td>
</tr>
<tr>
<td>Revert deployment</td>
<td>Access previous versions in Deploy tab → **Promote to live**</td>
<td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td>
</tr>
<tr>
<td>Copy API endpoint</td>
<td>Deploy tab → Copy API endpoint URL</td>
<td><ActionImage src="/static/quick-reference/copy-api.png" alt="Copy API endpoint" /></td>
</tr>
</tbody>
</table>
## Variables
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Add / Edit / Delete workflow variable</td>
<td>Panel -> Variables -> **Add Variable**, click to edit, or delete icon</td>
<td><ActionImage src="/static/quick-reference/variables.png" alt="Variables panel" /></td>
</tr>
<tr>
<td>Add environment variable</td>
<td>Settings → **Environment Variables** → **Add**</td>
<td><ActionImage src="/static/quick-reference/add-env-variable.png" alt="Add environment variable" /></td>
</tr>
<tr>
<td>Reference a workflow variable</td>
<td>Use `<blockName.itemName>` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/variable-reference.png" alt="Reference workflow variable" /></td>
</tr>
<tr>
<td>Reference an environment variable</td>
<td>Use `&#123;&#123;ENV_VAR&#125;&#125;` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/env-variable-reference.png" alt="Reference environment variable" /></td>
</tr>
</tbody>
</table>

View File

@@ -16,12 +16,20 @@ Deploy Sim on your own infrastructure with Docker or Kubernetes.
## Requirements ## Requirements
| Resource | Minimum | Recommended | | Resource | Small | Standard | Production |
|----------|---------|-------------| |----------|-------|----------|------------|
| CPU | 2 cores | 4+ cores | | CPU | 2 cores | 4 cores | 8+ cores |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| Storage | 20 GB SSD | 50+ GB SSD | | Storage | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | Latest | | Docker | 20.10+ | 20.10+ | Latest |
**Small**: Development, testing, single user (1-5 users)
**Standard**: Teams (5-50 users), moderate workloads
**Production**: Large teams (50+ users), high availability, heavy workflow execution
<Callout type="info">
Resource requirements are driven by workflow execution (isolated-vm sandboxing), file processing (in-memory document parsing), and vector operations (pgvector). Memory is typically the constraining factor rather than CPU. Production telemetry shows the main app uses 4-8 GB average with peaks up to 12 GB under heavy load.
</Callout>
## Quick Start ## Quick Start

View File

@@ -10,12 +10,20 @@ Despliega Sim en tu propia infraestructura con Docker o Kubernetes.
## Requisitos ## Requisitos
| Recurso | Mínimo | Recomendado | | Recurso | Pequeño | Estándar | Producción |
|----------|---------|-------------| |----------|---------|----------|------------|
| CPU | 2 núcleos | 4+ núcleos | | CPU | 2 núcleos | 4 núcleos | 8+ núcleos |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| Almacenamiento | 20 GB SSD | 50+ GB SSD | | Almacenamiento | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | Última versión | | Docker | 20.10+ | 20.10+ | Última versión |
**Pequeño**: Desarrollo, pruebas, usuario único (1-5 usuarios)
**Estándar**: Equipos (5-50 usuarios), cargas de trabajo moderadas
**Producción**: Equipos grandes (50+ usuarios), alta disponibilidad, ejecución intensiva de workflows
<Callout type="info">
Los requisitos de recursos están determinados por la ejecución de workflows (sandboxing isolated-vm), procesamiento de archivos (análisis de documentos en memoria) y operaciones vectoriales (pgvector). La memoria suele ser el factor limitante, no la CPU. La telemetría de producción muestra que la aplicación principal usa 4-8 GB en promedio con picos de hasta 12 GB bajo carga pesada.
</Callout>
## Inicio rápido ## Inicio rápido

View File

@@ -10,12 +10,20 @@ Déployez Sim sur votre propre infrastructure avec Docker ou Kubernetes.
## Prérequis ## Prérequis
| Ressource | Minimum | Recommandé | | Ressource | Petit | Standard | Production |
|----------|---------|-------------| |----------|-------|----------|------------|
| CPU | 2 cœurs | 4+ cœurs | | CPU | 2 cœurs | 4 cœurs | 8+ cœurs |
| RAM | 12 Go | 16+ Go | | RAM | 12 Go | 16 Go | 32+ Go |
| Stockage | 20 Go SSD | 50+ Go SSD | | Stockage | 20 Go SSD | 50 Go SSD | 100+ Go SSD |
| Docker | 20.10+ | Dernière version | | Docker | 20.10+ | 20.10+ | Dernière version |
**Petit** : Développement, tests, utilisateur unique (1-5 utilisateurs)
**Standard** : Équipes (5-50 utilisateurs), charges de travail modérées
**Production** : Grandes équipes (50+ utilisateurs), haute disponibilité, exécution intensive de workflows
<Callout type="info">
Les besoins en ressources sont déterminés par l'exécution des workflows (sandboxing isolated-vm), le traitement des fichiers (analyse de documents en mémoire) et les opérations vectorielles (pgvector). La mémoire est généralement le facteur limitant, pas le CPU. La télémétrie de production montre que l'application principale utilise 4-8 Go en moyenne avec des pics jusqu'à 12 Go sous forte charge.
</Callout>
## Démarrage rapide ## Démarrage rapide

View File

@@ -10,12 +10,20 @@ DockerまたはKubernetesを使用して、自社のインフラストラクチ
## 要件 ## 要件
| リソース | 最小 | 推奨 | | リソース | スモール | スタンダード | プロダクション |
|----------|---------|-------------| |----------|---------|-------------|----------------|
| CPU | 2コア | 4+コア | | CPU | 2コア | 4コア | 8+コア |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| ストレージ | 20 GB SSD | 50+ GB SSD | | ストレージ | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | 最新版 | | Docker | 20.10+ | 20.10+ | 最新版 |
**スモール**: 開発、テスト、シングルユーザー1-5ユーザー
**スタンダード**: チーム5-50ユーザー、中程度のワークロード
**プロダクション**: 大規模チーム50+ユーザー)、高可用性、高負荷ワークフロー実行
<Callout type="info">
リソース要件は、ワークフロー実行isolated-vmサンドボックス、ファイル処理メモリ内ドキュメント解析、ベクトル演算pgvectorによって決まります。CPUよりもメモリが制約要因となることが多いです。本番環境のテレメトリによると、メインアプリは平均4-8 GB、高負荷時は最大12 GBを使用します。
</Callout>
## クイックスタート ## クイックスタート

View File

@@ -10,12 +10,20 @@ import { Callout } from 'fumadocs-ui/components/callout'
## 要求 ## 要求
| 资源 | 最低要求 | 推荐配置 | | 资源 | 小型 | 标准 | 生产环境 |
|----------|---------|-------------| |----------|------|------|----------|
| CPU | 2 核 | 4 核及以上 | | CPU | 2 核 | 4 核 | 8+ 核 |
| 内存 | 12 GB | 16 GB 及以上 | | 内存 | 12 GB | 16 GB | 32+ GB |
| 存储 | 20 GB SSD | 50 GB 及以上 SSD | | 存储 | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | 最新版本 | | Docker | 20.10+ | 20.10+ | 最新版本 |
**小型**: 开发、测试、单用户1-5 用户)
**标准**: 团队5-50 用户)、中等工作负载
**生产环境**: 大型团队50+ 用户)、高可用性、密集工作流执行
<Callout type="info">
资源需求由工作流执行isolated-vm 沙箱、文件处理内存中文档解析和向量运算pgvector决定。内存通常是限制因素而不是 CPU。生产遥测数据显示主应用平均使用 4-8 GB高负载时峰值可达 12 GB。
</Callout>
## 快速开始 ## 快速开始

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -10,8 +10,8 @@ export { LandingLoopNode } from './landing-canvas/landing-block/landing-loop-nod
export { LandingNode } from './landing-canvas/landing-block/landing-node' export { LandingNode } from './landing-canvas/landing-block/landing-node'
export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block' export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block'
export { LoopBlock } from './landing-canvas/landing-block/loop-block' export { LoopBlock } from './landing-canvas/landing-block/loop-block'
export type { TagProps } from './landing-canvas/landing-block/tag' export type { SubBlockRowProps, TagProps } from './landing-canvas/landing-block/tag'
export { Tag } from './landing-canvas/landing-block/tag' export { SubBlockRow, Tag } from './landing-canvas/landing-block/tag'
export type { export type {
LandingBlockNode, LandingBlockNode,
LandingCanvasProps, LandingCanvasProps,

View File

@@ -1,12 +1,12 @@
import React from 'react' import React from 'react'
import { BookIcon } from 'lucide-react'
import { import {
Tag, SubBlockRow,
type TagProps, type SubBlockRowProps,
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/tag' } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/tag'
/** /**
* Data structure for a landing card component * Data structure for a landing card component
* Matches the workflow block structure from the application
*/ */
export interface LandingCardData { export interface LandingCardData {
/** Icon element to display in the card header */ /** Icon element to display in the card header */
@@ -15,8 +15,8 @@ export interface LandingCardData {
color: string | '#f6f6f6' color: string | '#f6f6f6'
/** Name/title of the card */ /** Name/title of the card */
name: string name: string
/** Optional tags to display at the bottom of the card */ /** Optional subblock rows to display below the header */
tags?: TagProps[] tags?: SubBlockRowProps[]
} }
/** /**
@@ -28,7 +28,8 @@ export interface LandingBlockProps extends LandingCardData {
} }
/** /**
* Landing block component that displays a card with icon, name, and optional tags * Landing block component that displays a card with icon, name, and optional subblock rows
* Styled to match the application's workflow blocks
* @param props - Component properties including icon, color, name, tags, and className * @param props - Component properties including icon, color, name, tags, and className
* @returns A styled block card component * @returns A styled block card component
*/ */
@@ -39,33 +40,37 @@ export const LandingBlock = React.memo(function LandingBlock({
tags, tags,
className, className,
}: LandingBlockProps) { }: LandingBlockProps) {
const hasContentBelowHeader = tags && tags.length > 0
return ( return (
<div <div
className={`z-10 flex w-64 flex-col items-start gap-3 rounded-[14px] border border-[#E5E5E5] bg-[#FEFEFE] p-3 ${className ?? ''}`} className={`z-10 flex w-[250px] flex-col rounded-[8px] border border-[#E5E5E5] bg-white ${className ?? ''}`}
style={{
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
}}
> >
<div className='flex w-full items-center justify-between'> {/* Header - matches workflow-block.tsx header styling */}
<div className='flex items-center gap-2.5'> <div
className={`flex items-center justify-between p-[8px] ${hasContentBelowHeader ? 'border-[#E5E5E5] border-b' : ''}`}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div <div
className='flex h-6 w-6 items-center justify-center rounded-[8px] text-white' className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: color as string }} style={{ background: color as string }}
> >
{icon} {icon}
</div> </div>
<p className='text-base text-card-foreground'>{name}</p> <span className='truncate font-medium text-[#171717] text-[16px]' title={name}>
{name}
</span>
</div> </div>
<BookIcon className='h-4 w-4 text-muted-foreground' />
</div> </div>
{tags && tags.length > 0 ? ( {/* Content - SubBlock Rows matching workflow-block.tsx */}
<div className='flex flex-wrap gap-2'> {hasContentBelowHeader && (
<div className='flex flex-col gap-[8px] p-[8px]'>
{tags.map((tag) => ( {tags.map((tag) => (
<Tag key={tag.label} icon={tag.icon} label={tag.label} /> <SubBlockRow key={tag.label} icon={tag.icon} label={tag.label} />
))} ))}
</div> </div>
) : null} )}
</div> </div>
) )
}) })

View File

@@ -7,9 +7,14 @@ import {
type LandingCardData, type LandingCardData,
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-block' } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-block'
/**
* Handle Y offset from block top - matches HANDLE_POSITIONS.DEFAULT_Y_OFFSET
*/
const HANDLE_Y_OFFSET = 20
/** /**
* React Flow node component for the landing canvas * React Flow node component for the landing canvas
* Includes CSS animations and connection handles * Styled to match the application's workflow blocks
* @param props - Component properties containing node data * @param props - Component properties containing node data
* @returns A React Flow compatible node component * @returns A React Flow compatible node component
*/ */
@@ -41,15 +46,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
type='target' type='target'
position={Position.Left} position={Position.Left}
style={{ style={{
width: '12px', width: '7px',
height: '12px', height: '20px',
background: '#FEFEFE', background: '#D1D1D1',
border: '1px solid #E5E5E5', border: 'none',
borderRadius: '50%', borderRadius: '2px 0 0 2px',
top: '50%', top: `${HANDLE_Y_OFFSET}px`,
left: '-20px', left: '-7px',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 2, zIndex: 10,
}} }}
isConnectable={false} isConnectable={false}
/> />
@@ -59,15 +64,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
type='source' type='source'
position={Position.Right} position={Position.Right}
style={{ style={{
width: '12px', width: '7px',
height: '12px', height: '20px',
background: '#FEFEFE', background: '#D1D1D1',
border: '1px solid #E5E5E5', border: 'none',
borderRadius: '50%', borderRadius: '0 2px 2px 0',
top: '50%', top: `${HANDLE_Y_OFFSET}px`,
right: '-20px', right: '-7px',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 2, zIndex: 10,
}} }}
isConnectable={false} isConnectable={false}
/> />

View File

@@ -15,6 +15,7 @@ export interface LoopBlockProps {
/** /**
* Loop block container component that provides a styled container * Loop block container component that provides a styled container
* for grouping related elements with a dashed border * for grouping related elements with a dashed border
* Styled to match the application's subflow containers
* @param props - Component properties including children and styling * @param props - Component properties including children and styling
* @returns A styled loop container component * @returns A styled loop container component
*/ */
@@ -29,33 +30,33 @@ export const LoopBlock = React.memo(function LoopBlock({
style={{ style={{
width: '1198px', width: '1198px',
height: '528px', height: '528px',
borderRadius: '14px', borderRadius: '8px',
background: 'rgba(59, 130, 246, 0.10)', background: 'rgba(59, 130, 246, 0.08)',
position: 'relative', position: 'relative',
...style, ...style,
}} }}
> >
{/* Custom dashed border with SVG */} {/* Custom dashed border with SVG - 8px border radius to match blocks */}
<svg <svg
className='pointer-events-none absolute inset-0 h-full w-full' className='pointer-events-none absolute inset-0 h-full w-full'
style={{ borderRadius: '14px' }} style={{ borderRadius: '8px' }}
preserveAspectRatio='none' preserveAspectRatio='none'
> >
<path <path
className='landing-loop-animated-dash' className='landing-loop-animated-dash'
d='M 1183.5 527.5 d='M 1190 527.5
L 14 527.5 L 8 527.5
A 13.5 13.5 0 0 1 0.5 514 A 7.5 7.5 0 0 1 0.5 520
L 0.5 14 L 0.5 8
A 13.5 13.5 0 0 1 14 0.5 A 7.5 7.5 0 0 1 8 0.5
L 1183.5 0.5 L 1190 0.5
A 13.5 13.5 0 0 1 1197 14 A 7.5 7.5 0 0 1 1197.5 8
L 1197 514 L 1197.5 520
A 13.5 13.5 0 0 1 1183.5 527.5 Z' A 7.5 7.5 0 0 1 1190 527.5 Z'
fill='none' fill='none'
stroke='#3B82F6' stroke='#3B82F6'
strokeWidth='1' strokeWidth='1'
strokeDasharray='12 12' strokeDasharray='8 8'
strokeLinecap='round' strokeLinecap='round'
/> />
</svg> </svg>

View File

@@ -1,25 +1,52 @@
import React from 'react' import React from 'react'
/** /**
* Properties for a tag component * Properties for a subblock row component
* Matches the SubBlockRow pattern from workflow-block.tsx
*/ */
export interface TagProps { export interface SubBlockRowProps {
/** Icon element to display in the tag */ /** Icon element to display (optional, for visual context) */
icon: React.ReactNode icon?: React.ReactNode
/** Text label for the tag */ /** Text label for the row title */
label: string label: string
/** Optional value to display on the right side */
value?: string
} }
/** /**
* Tag component for displaying labeled icons in a compact format * Kept for backwards compatibility
* @param props - Tag properties including icon and label
* @returns A styled tag component
*/ */
export const Tag = React.memo(function Tag({ icon, label }: TagProps) { export type TagProps = SubBlockRowProps
/**
* SubBlockRow component matching the workflow block's subblock row style
* @param props - Row properties including label and optional value
* @returns A styled row component
*/
export const SubBlockRow = React.memo(function SubBlockRow({ label, value }: SubBlockRowProps) {
// Split label by colon to separate title and value if present
const [title, displayValue] = label.includes(':')
? label.split(':').map((s) => s.trim())
: [label, value]
return ( return (
<div className='flex w-fit items-center gap-1 rounded-[8px] border border-gray-300 bg-white px-2 py-0.5'> <div className='flex items-center gap-[8px]'>
<div className='h-3 w-3 text-muted-foreground'>{icon}</div> <span className='min-w-0 truncate text-[#888888] text-[14px] capitalize' title={title}>
<p className='text-muted-foreground text-xs leading-normal'>{label}</p> {title}
</span>
{displayValue && (
<span
className='flex-1 truncate text-right text-[#171717] text-[14px]'
title={displayValue}
>
{displayValue}
</span>
)}
</div> </div>
) )
}) })
/**
* Tag component - alias for SubBlockRow for backwards compatibility
*/
export const Tag = SubBlockRow

View File

@@ -9,9 +9,10 @@ import { LandingFlow } from '@/app/(landing)/components/hero/components/landing-
/** /**
* Visual constants for landing node dimensions * Visual constants for landing node dimensions
* Matches BLOCK_DIMENSIONS from the application
*/ */
export const CARD_WIDTH = 256 export const CARD_WIDTH = 250
export const CARD_HEIGHT = 92 export const CARD_HEIGHT = 100
/** /**
* Landing block node with positioning information * Landing block node with positioning information

View File

@@ -4,33 +4,29 @@ import React from 'react'
import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow' import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow'
/** /**
* Custom edge component with animated dotted line that floats between handles * Custom edge component with animated dashed line
* Styled to match the application's workflow edges with rectangular handles
* @param props - React Flow edge properties * @param props - React Flow edge properties
* @returns An animated dotted edge component * @returns An animated dashed edge component
*/ */
export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) { export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data } = const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style } = props
props
// Adjust the connection points to create floating effect // Adjust the connection points to connect flush with rectangular handles
// Account for handle size (12px) and additional spacing // Handle width is 7px, positioned at -7px from edge
const handleRadius = 6 // Half of handle width (12px)
const floatingGap = 1 // Additional gap for floating effect
// Calculate adjusted positions based on edge direction
let adjustedSourceX = sourceX let adjustedSourceX = sourceX
let adjustedTargetX = targetX let adjustedTargetX = targetX
if (sourcePosition === Position.Right) { if (sourcePosition === Position.Right) {
adjustedSourceX = sourceX + handleRadius + floatingGap adjustedSourceX = sourceX + 1
} else if (sourcePosition === Position.Left) { } else if (sourcePosition === Position.Left) {
adjustedSourceX = sourceX - handleRadius - floatingGap adjustedSourceX = sourceX - 1
} }
if (targetPosition === Position.Left) { if (targetPosition === Position.Left) {
adjustedTargetX = targetX - handleRadius - floatingGap adjustedTargetX = targetX - 1
} else if (targetPosition === Position.Right) { } else if (targetPosition === Position.Right) {
adjustedTargetX = targetX + handleRadius + floatingGap adjustedTargetX = targetX + 1
} }
const [path] = getSmoothStepPath({ const [path] = getSmoothStepPath({
@@ -40,8 +36,8 @@ export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
targetY, targetY,
sourcePosition, sourcePosition,
targetPosition, targetPosition,
borderRadius: 20, borderRadius: 8,
offset: 10, offset: 16,
}) })
return ( return (

View File

@@ -1,16 +1,7 @@
'use client' 'use client'
import React from 'react' import React from 'react'
import { import { ArrowUp, CodeIcon } from 'lucide-react'
ArrowUp,
BinaryIcon,
BookIcon,
CalendarIcon,
CodeIcon,
Globe2Icon,
MessageSquareIcon,
VariableIcon,
} from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { type Edge, type Node, Position } from 'reactflow' import { type Edge, type Node, Position } from 'reactflow'
import { import {
@@ -23,7 +14,6 @@ import {
JiraIcon, JiraIcon,
LinearIcon, LinearIcon,
NotionIcon, NotionIcon,
OpenAIIcon,
OutlookIcon, OutlookIcon,
PackageSearchIcon, PackageSearchIcon,
PineconeIcon, PineconeIcon,
@@ -65,67 +55,56 @@ const SERVICE_TEMPLATES = {
/** /**
* Landing blocks for the canvas preview * Landing blocks for the canvas preview
* Styled to match the application's workflow blocks with subblock rows
*/ */
const LANDING_BLOCKS: LandingManualBlock[] = [ const LANDING_BLOCKS: LandingManualBlock[] = [
{ {
id: 'schedule', id: 'schedule',
name: 'Schedule', name: 'Schedule',
color: '#7B68EE', color: '#7B68EE',
icon: <ScheduleIcon className='h-4 w-4' />, icon: <ScheduleIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 8, y: 60 }, mobile: { x: 8, y: 60 },
tablet: { x: 40, y: 120 }, tablet: { x: 40, y: 120 },
desktop: { x: 60, y: 180 }, desktop: { x: 60, y: 180 },
}, },
tags: [ tags: [{ label: 'Time: 09:00AM Daily' }, { label: 'Timezone: PST' }],
{ icon: <CalendarIcon className='h-3 w-3' />, label: '09:00AM Daily' },
{ icon: <Globe2Icon className='h-3 w-3' />, label: 'PST' },
],
}, },
{ {
id: 'knowledge', id: 'knowledge',
name: 'Knowledge', name: 'Knowledge',
color: '#00B0B0', color: '#00B0B0',
icon: <PackageSearchIcon className='h-4 w-4' />, icon: <PackageSearchIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 120, y: 140 }, mobile: { x: 120, y: 140 },
tablet: { x: 220, y: 200 }, tablet: { x: 220, y: 200 },
desktop: { x: 420, y: 241 }, desktop: { x: 420, y: 241 },
}, },
tags: [ tags: [{ label: 'Source: Product Vector DB' }, { label: 'Limit: 10' }],
{ icon: <BookIcon className='h-3 w-3' />, label: 'Product Vector DB' },
{ icon: <BinaryIcon className='h-3 w-3' />, label: 'Limit: 10' },
],
}, },
{ {
id: 'agent', id: 'agent',
name: 'Agent', name: 'Agent',
color: '#802FFF', color: '#802FFF',
icon: <AgentIcon className='h-4 w-4' />, icon: <AgentIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 340, y: 60 }, mobile: { x: 340, y: 60 },
tablet: { x: 540, y: 120 }, tablet: { x: 540, y: 120 },
desktop: { x: 880, y: 142 }, desktop: { x: 880, y: 142 },
}, },
tags: [ tags: [{ label: 'Model: gpt-5' }, { label: 'Prompt: You are a support ag...' }],
{ icon: <OpenAIIcon className='h-3 w-3' />, label: 'gpt-5' },
{ icon: <MessageSquareIcon className='h-3 w-3' />, label: 'You are a support ag...' },
],
}, },
{ {
id: 'function', id: 'function',
name: 'Function', name: 'Function',
color: '#FF402F', color: '#FF402F',
icon: <CodeIcon className='h-4 w-4' />, icon: <CodeIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 480, y: 220 }, mobile: { x: 480, y: 220 },
tablet: { x: 740, y: 280 }, tablet: { x: 740, y: 280 },
desktop: { x: 880, y: 340 }, desktop: { x: 880, y: 340 },
}, },
tags: [ tags: [{ label: 'Language: Python' }, { label: 'Code: time = "2025-09-01...' }],
{ icon: <CodeIcon className='h-3 w-3' />, label: 'Python' },
{ icon: <VariableIcon className='h-3 w-3' />, label: 'time = "2025-09-01...' },
],
}, },
] ]

View File

@@ -229,7 +229,7 @@ function PricingCard({
*/ */
export default function LandingPricing() { export default function LandingPricing() {
return ( return (
<section id='pricing' className='px-4 pt-[19px] sm:px-0 sm:pt-0' aria-label='Pricing plans'> <section id='pricing' className='px-4 pt-[23px] sm:px-0 sm:pt-[4px]' aria-label='Pricing plans'>
<h2 className='sr-only'>Pricing Plans</h2> <h2 className='sr-only'>Pricing Plans</h2>
<div className='relative mx-auto w-full max-w-[1289px]'> <div className='relative mx-auto w-full max-w-[1289px]'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0 lg:grid-cols-4'> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0 lg:grid-cols-4'>

View File

@@ -21,7 +21,7 @@ interface NavProps {
} }
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) { export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('25.8k') const [githubStars, setGithubStars] = useState('26.1k')
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false) const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter() const router = useRouter()

View File

@@ -408,6 +408,7 @@ describe('Knowledge Search Utils', () => {
input: ['test query'], input: ['test query'],
model: 'text-embedding-3-small', model: 'text-embedding-3-small',
encoding_format: 'float', encoding_format: 'float',
dimensions: 1536,
}), }),
}) })
) )

View File

@@ -1,204 +0,0 @@
import { db } from '@sim/db'
import { member, permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
const logger = createLogger('OrganizationWorkspacesAPI')
/**
* GET /api/organizations/[id]/workspaces
* Get workspaces related to the organization with optional filtering
* Query parameters:
* - ?available=true - Only workspaces where user can invite others (admin permissions)
* - ?member=userId - Workspaces where specific member has access
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const availableOnly = url.searchParams.get('available') === 'true'
const memberId = url.searchParams.get('member')
// Verify user is a member of this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{
error: 'Forbidden - Not a member of this organization',
},
{ status: 403 }
)
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
if (availableOnly) {
// Get workspaces where user has admin permissions (can invite others)
const availableWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
isOwner: eq(workspace.ownerId, session.user.id),
permissionType: permissions.permissionType,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, session.user.id)
)
)
.where(
or(
// User owns the workspace
eq(workspace.ownerId, session.user.id),
// User has admin permission on the workspace
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.permissionType, 'admin')
)
)
)
// Filter and format the results
const workspacesWithInvitePermission = availableWorkspaces
.filter((workspace) => {
// Include if user owns the workspace OR has admin permission
return workspace.isOwner || workspace.permissionType === 'admin'
})
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
canInvite: true, // All returned workspaces have invite permission
createdAt: workspace.createdAt,
}))
logger.info('Retrieved available workspaces for organization member', {
organizationId,
userId: session.user.id,
workspaceCount: workspacesWithInvitePermission.length,
})
return NextResponse.json({
success: true,
data: {
workspaces: workspacesWithInvitePermission,
totalCount: workspacesWithInvitePermission.length,
filter: 'available',
},
})
}
if (memberId && hasAdminAccess) {
// Get workspaces where specific member has access (admin only)
const memberWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
isOwner: eq(workspace.ownerId, memberId),
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, memberId)
)
)
.where(
or(
// Member owns the workspace
eq(workspace.ownerId, memberId),
// Member has permissions on the workspace
and(eq(permissions.userId, memberId), eq(permissions.entityType, 'workspace'))
)
)
const formattedWorkspaces = memberWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
permission: workspace.permissionType,
joinedAt: workspace.createdAt,
createdAt: workspace.createdAt,
}))
return NextResponse.json({
success: true,
data: {
workspaces: formattedWorkspaces,
totalCount: formattedWorkspaces.length,
filter: 'member',
memberId,
},
})
}
// Default: Get all workspaces (basic info only for regular members)
if (!hasAdminAccess) {
return NextResponse.json({
success: true,
data: {
workspaces: [],
totalCount: 0,
message: 'Workspace access information is only available to organization admins',
},
})
}
// For admins: Get summary of all workspaces
const allWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
ownerName: user.name,
})
.from(workspace)
.leftJoin(user, eq(workspace.ownerId, user.id))
return NextResponse.json({
success: true,
data: {
workspaces: allWorkspaces,
totalCount: allWorkspaces.length,
filter: 'all',
},
userRole,
hasAdminAccess,
})
} catch (error) {
logger.error('Failed to get organization workspaces', { error })
return NextResponse.json(
{
error: 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,257 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('SupabaseStorageUploadAPI')
const SupabaseStorageUploadSchema = z.object({
projectId: z.string().min(1, 'Project ID is required'),
apiKey: z.string().min(1, 'API key is required'),
bucket: z.string().min(1, 'Bucket name is required'),
fileName: z.string().min(1, 'File name is required'),
path: z.string().optional().nullable(),
fileData: z.any(),
contentType: z.string().optional().nullable(),
upsert: z.boolean().optional().default(false),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated Supabase storage upload request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = SupabaseStorageUploadSchema.parse(body)
const fileData = validatedData.fileData
const isStringInput = typeof fileData === 'string'
logger.info(`[${requestId}] Uploading to Supabase Storage`, {
bucket: validatedData.bucket,
fileName: validatedData.fileName,
path: validatedData.path,
fileDataType: isStringInput ? 'string' : 'object',
})
if (!fileData) {
return NextResponse.json(
{
success: false,
error: 'fileData is required',
},
{ status: 400 }
)
}
let uploadBody: Buffer
let uploadContentType: string | undefined
if (isStringInput) {
let content = fileData as string
const dataUrlMatch = content.match(/^data:([^;]+);base64,(.+)$/s)
if (dataUrlMatch) {
const [, mimeType, base64Data] = dataUrlMatch
content = base64Data
if (!validatedData.contentType) {
uploadContentType = mimeType
}
logger.info(`[${requestId}] Extracted base64 from data URL (MIME: ${mimeType})`)
}
const cleanedContent = content.replace(/[\s\r\n]/g, '')
const isLikelyBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(cleanedContent)
if (isLikelyBase64 && cleanedContent.length >= 4) {
try {
uploadBody = Buffer.from(cleanedContent, 'base64')
const expectedMinSize = Math.floor(cleanedContent.length * 0.7)
const expectedMaxSize = Math.ceil(cleanedContent.length * 0.8)
if (
uploadBody.length >= expectedMinSize &&
uploadBody.length <= expectedMaxSize &&
uploadBody.length > 0
) {
logger.info(
`[${requestId}] Decoded base64 content: ${cleanedContent.length} chars -> ${uploadBody.length} bytes`
)
} else {
const reEncoded = uploadBody.toString('base64')
if (reEncoded !== cleanedContent) {
logger.info(
`[${requestId}] Content looked like base64 but re-encoding didn't match, using as plain text`
)
uploadBody = Buffer.from(content, 'utf-8')
} else {
logger.info(
`[${requestId}] Decoded base64 content (verified): ${uploadBody.length} bytes`
)
}
}
} catch (decodeError) {
logger.info(
`[${requestId}] Failed to decode as base64, using as plain text: ${decodeError}`
)
uploadBody = Buffer.from(content, 'utf-8')
}
} else {
uploadBody = Buffer.from(content, 'utf-8')
logger.info(`[${requestId}] Using content as plain text (${uploadBody.length} bytes)`)
}
uploadContentType =
uploadContentType || validatedData.contentType || 'application/octet-stream'
} else {
const rawFile = fileData
logger.info(`[${requestId}] Processing file object: ${rawFile.name || 'unknown'}`)
let userFile
try {
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to process file',
},
{ status: 400 }
)
}
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
uploadBody = buffer
uploadContentType = validatedData.contentType || userFile.type || 'application/octet-stream'
}
let fullPath = validatedData.fileName
if (validatedData.path) {
const folderPath = validatedData.path.endsWith('/')
? validatedData.path
: `${validatedData.path}/`
fullPath = `${folderPath}${validatedData.fileName}`
}
const supabaseUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}`
const headers: Record<string, string> = {
apikey: validatedData.apiKey,
Authorization: `Bearer ${validatedData.apiKey}`,
'Content-Type': uploadContentType,
}
if (validatedData.upsert) {
headers['x-upsert'] = 'true'
}
logger.info(`[${requestId}] Sending to Supabase: ${supabaseUrl}`, {
contentType: uploadContentType,
bodySize: uploadBody.length,
upsert: validatedData.upsert,
})
const response = await fetch(supabaseUrl, {
method: 'POST',
headers,
body: new Uint8Array(uploadBody),
})
if (!response.ok) {
const errorText = await response.text()
let errorData
try {
errorData = JSON.parse(errorText)
} catch {
errorData = { message: errorText }
}
logger.error(`[${requestId}] Supabase Storage upload failed:`, {
status: response.status,
statusText: response.statusText,
error: errorData,
})
return NextResponse.json(
{
success: false,
error: errorData.message || errorData.error || `Upload failed: ${response.statusText}`,
details: errorData,
},
{ status: response.status }
)
}
const result = await response.json()
logger.info(`[${requestId}] File uploaded successfully to Supabase Storage`, {
bucket: validatedData.bucket,
path: fullPath,
})
const publicUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}`
return NextResponse.json({
success: true,
output: {
message: 'Successfully uploaded file to storage',
results: {
...result,
path: fullPath,
bucket: validatedData.bucket,
publicUrl,
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error uploading to Supabase Storage:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -1,7 +1,15 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Badge, Button, Combobox, Input, Label, Textarea } from '@/components/emcn' import {
Badge,
Button,
Combobox,
type ComboboxOption,
Input,
Label,
Textarea,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash' import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
@@ -38,6 +46,14 @@ const DEFAULT_ASSIGNMENT: Omit<VariableAssignment, 'id'> = {
isExisting: false, isExisting: false,
} }
/**
* Boolean value options for Combobox
*/
const BOOLEAN_OPTIONS: ComboboxOption[] = [
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]
/** /**
* Parses a value that might be a JSON string or already an array of VariableAssignment. * Parses a value that might be a JSON string or already an array of VariableAssignment.
* This handles the case where workflows are imported with stringified values. * This handles the case where workflows are imported with stringified values.
@@ -104,8 +120,6 @@ export function VariablesInput({
const allVariablesAssigned = const allVariablesAssigned =
!hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0 !hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0
// Initialize with one empty assignment if none exist and not in preview/disabled mode
// Also add assignment when first variable is created
useEffect(() => { useEffect(() => {
if (!isReadOnly && assignments.length === 0 && currentWorkflowVariables.length > 0) { if (!isReadOnly && assignments.length === 0 && currentWorkflowVariables.length > 0) {
const initialAssignment: VariableAssignment = { const initialAssignment: VariableAssignment = {
@@ -116,45 +130,46 @@ export function VariablesInput({
} }
}, [currentWorkflowVariables.length, isReadOnly, assignments.length, setStoreValue]) }, [currentWorkflowVariables.length, isReadOnly, assignments.length, setStoreValue])
// Clean up assignments when their associated variables are deleted
useEffect(() => { useEffect(() => {
if (isReadOnly || assignments.length === 0) return if (isReadOnly || assignments.length === 0) return
const currentVariableIds = new Set(currentWorkflowVariables.map((v) => v.id)) const currentVariableIds = new Set(currentWorkflowVariables.map((v) => v.id))
const validAssignments = assignments.filter((assignment) => { const validAssignments = assignments.filter((assignment) => {
// Keep assignments that haven't selected a variable yet
if (!assignment.variableId) return true if (!assignment.variableId) return true
// Keep assignments whose variable still exists
return currentVariableIds.has(assignment.variableId) return currentVariableIds.has(assignment.variableId)
}) })
// If all variables were deleted, clear all assignments
if (currentWorkflowVariables.length === 0) { if (currentWorkflowVariables.length === 0) {
setStoreValue([]) setStoreValue([])
} else if (validAssignments.length !== assignments.length) { } else if (validAssignments.length !== assignments.length) {
// Some assignments reference deleted variables, remove them
setStoreValue(validAssignments.length > 0 ? validAssignments : []) setStoreValue(validAssignments.length > 0 ? validAssignments : [])
} }
}, [currentWorkflowVariables, assignments, isReadOnly, setStoreValue]) }, [currentWorkflowVariables, assignments, isReadOnly, setStoreValue])
const addAssignment = () => { const addAssignment = () => {
if (isPreview || disabled || allVariablesAssigned) return if (isReadOnly || allVariablesAssigned) return
const newAssignment: VariableAssignment = { const newAssignment: VariableAssignment = {
...DEFAULT_ASSIGNMENT, ...DEFAULT_ASSIGNMENT,
id: crypto.randomUUID(), id: crypto.randomUUID(),
} }
setStoreValue([...(assignments || []), newAssignment]) setStoreValue([...assignments, newAssignment])
} }
const removeAssignment = (id: string) => { const removeAssignment = (id: string) => {
if (isPreview || disabled) return if (isReadOnly) return
setStoreValue((assignments || []).filter((a) => a.id !== id))
if (assignments.length === 1) {
setStoreValue([{ ...DEFAULT_ASSIGNMENT, id: crypto.randomUUID() }])
return
}
setStoreValue(assignments.filter((a) => a.id !== id))
} }
const updateAssignment = (id: string, updates: Partial<VariableAssignment>) => { const updateAssignment = (id: string, updates: Partial<VariableAssignment>) => {
if (isPreview || disabled) return if (isReadOnly) return
setStoreValue((assignments || []).map((a) => (a.id === id ? { ...a, ...updates } : a))) setStoreValue(assignments.map((a) => (a.id === id ? { ...a, ...updates } : a)))
} }
const handleVariableSelect = (assignmentId: string, variableId: string) => { const handleVariableSelect = (assignmentId: string, variableId: string) => {
@@ -169,19 +184,12 @@ export function VariablesInput({
} }
} }
const handleTagSelect = (tag: string) => { const handleTagSelect = (newValue: string) => {
if (!activeFieldId) return if (!activeFieldId) return
const assignment = assignments.find((a) => a.id === activeFieldId) const assignment = assignments.find((a) => a.id === activeFieldId)
if (!assignment) return const originalValue = assignment?.value || ''
const textAfterCursor = originalValue.slice(cursorPosition)
const currentValue = assignment.value || ''
const textBeforeCursor = currentValue.slice(0, cursorPosition)
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
const newValue =
currentValue.slice(0, lastOpenBracket) + tag + currentValue.slice(cursorPosition)
updateAssignment(activeFieldId, { value: newValue }) updateAssignment(activeFieldId, { value: newValue })
setShowTags(false) setShowTags(false)
@@ -190,7 +198,7 @@ export function VariablesInput({
const inputEl = valueInputRefs.current[activeFieldId] const inputEl = valueInputRefs.current[activeFieldId]
if (inputEl) { if (inputEl) {
inputEl.focus() inputEl.focus()
const newCursorPos = lastOpenBracket + tag.length const newCursorPos = newValue.length - textAfterCursor.length
inputEl.setSelectionRange(newCursorPos, newCursorPos) inputEl.setSelectionRange(newCursorPos, newCursorPos)
} }
}, 10) }, 10)
@@ -272,6 +280,18 @@ export function VariablesInput({
})) }))
} }
const syncOverlayScroll = (assignmentId: string, scrollLeft: number) => {
const overlay = overlayRefs.current[assignmentId]
if (overlay) overlay.scrollLeft = scrollLeft
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setShowTags(false)
setActiveSourceBlockId(null)
}
}
if (isPreview && (!assignments || assignments.length === 0)) { if (isPreview && (!assignments || assignments.length === 0)) {
return ( return (
<div className='flex flex-col items-center justify-center rounded-md border border-border/40 bg-muted/20 py-8 text-center'> <div className='flex flex-col items-center justify-center rounded-md border border-border/40 bg-muted/20 py-8 text-center'>
@@ -302,7 +322,7 @@ export function VariablesInput({
return ( return (
<div className='space-y-[8px]'> <div className='space-y-[8px]'>
{assignments && assignments.length > 0 && ( {assignments.length > 0 && (
<div className='space-y-[8px]'> <div className='space-y-[8px]'>
{assignments.map((assignment, index) => { {assignments.map((assignment, index) => {
const collapsed = collapsedAssignments[assignment.id] || false const collapsed = collapsedAssignments[assignment.id] || false
@@ -334,7 +354,7 @@ export function VariablesInput({
<Button <Button
variant='ghost' variant='ghost'
onClick={addAssignment} onClick={addAssignment}
disabled={isPreview || disabled || allVariablesAssigned} disabled={isReadOnly || allVariablesAssigned}
className='h-auto p-0' className='h-auto p-0'
> >
<Plus className='h-[14px] w-[14px]' /> <Plus className='h-[14px] w-[14px]' />
@@ -343,7 +363,7 @@ export function VariablesInput({
<Button <Button
variant='ghost' variant='ghost'
onClick={() => removeAssignment(assignment.id)} onClick={() => removeAssignment(assignment.id)}
disabled={isPreview || disabled || assignments.length === 1} disabled={isReadOnly}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]' className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
> >
<Trash className='h-[14px] w-[14px]' /> <Trash className='h-[14px] w-[14px]' />
@@ -358,16 +378,26 @@ export function VariablesInput({
<Label className='text-[13px]'>Variable</Label> <Label className='text-[13px]'>Variable</Label>
<Combobox <Combobox
options={availableVars.map((v) => ({ label: v.name, value: v.id }))} options={availableVars.map((v) => ({ label: v.name, value: v.id }))}
value={assignment.variableId || assignment.variableName || ''} value={assignment.variableId || ''}
onChange={(value) => handleVariableSelect(assignment.id, value)} onChange={(value) => handleVariableSelect(assignment.id, value)}
placeholder='Select a variable...' placeholder='Select a variable...'
disabled={isPreview || disabled} disabled={isReadOnly}
/> />
</div> </div>
<div className='flex flex-col gap-[6px]'> <div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label> <Label className='text-[13px]'>Value</Label>
{assignment.type === 'object' || assignment.type === 'array' ? ( {assignment.type === 'boolean' ? (
<Combobox
options={BOOLEAN_OPTIONS}
value={assignment.value ?? ''}
onChange={(v) =>
!isReadOnly && updateAssignment(assignment.id, { value: v })
}
placeholder='Select value'
disabled={isReadOnly}
/>
) : assignment.type === 'object' || assignment.type === 'array' ? (
<div className='relative'> <div className='relative'>
<Textarea <Textarea
ref={(el) => { ref={(el) => {
@@ -381,26 +411,32 @@ export function VariablesInput({
e.target.selectionStart ?? undefined e.target.selectionStart ?? undefined
) )
} }
onKeyDown={handleKeyDown}
onFocus={() => { onFocus={() => {
if (!isPreview && !disabled && !assignment.value?.trim()) { if (!isReadOnly && !assignment.value?.trim()) {
setActiveFieldId(assignment.id) setActiveFieldId(assignment.id)
setCursorPosition(0) setCursorPosition(0)
setShowTags(true) setShowTags(true)
} }
}} }}
onScroll={(e) => {
const overlay = overlayRefs.current[assignment.id]
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}}
placeholder={ placeholder={
assignment.type === 'object' assignment.type === 'object'
? '{\n "key": "value"\n}' ? '{\n "key": "value"\n}'
: '[\n 1, 2, 3\n]' : '[\n 1, 2, 3\n]'
} }
disabled={isPreview || disabled} disabled={isReadOnly}
className={cn( className={cn(
'min-h-[120px] font-mono text-sm text-transparent caret-foreground placeholder:text-muted-foreground/50', 'min-h-[120px] font-mono text-sm text-transparent caret-foreground placeholder:text-muted-foreground/50',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2' dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)} )}
style={{ style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
wordBreak: 'break-word', wordBreak: 'break-word',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
}} }}
@@ -413,10 +449,7 @@ export function VariablesInput({
if (el) overlayRefs.current[assignment.id] = el if (el) overlayRefs.current[assignment.id] = el
}} }}
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm' className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
style={{ style={{ scrollbarWidth: 'none' }}
fontFamily: 'inherit',
lineHeight: 'inherit',
}}
> >
<div className='w-full whitespace-pre-wrap break-words'> <div className='w-full whitespace-pre-wrap break-words'>
{formatDisplayText(assignment.value || '', { {formatDisplayText(assignment.value || '', {
@@ -441,21 +474,34 @@ export function VariablesInput({
e.target.selectionStart ?? undefined e.target.selectionStart ?? undefined
) )
} }
onKeyDown={handleKeyDown}
onFocus={() => { onFocus={() => {
if (!isPreview && !disabled && !assignment.value?.trim()) { if (!isReadOnly && !assignment.value?.trim()) {
setActiveFieldId(assignment.id) setActiveFieldId(assignment.id)
setCursorPosition(0) setCursorPosition(0)
setShowTags(true) setShowTags(true)
} }
}} }}
onScroll={(e) =>
syncOverlayScroll(assignment.id, e.currentTarget.scrollLeft)
}
onPaste={() =>
setTimeout(() => {
const input = valueInputRefs.current[assignment.id]
if (input)
syncOverlayScroll(
assignment.id,
(input as HTMLInputElement).scrollLeft
)
}, 0)
}
placeholder={`${assignment.type} value`} placeholder={`${assignment.type} value`}
disabled={isPreview || disabled} disabled={isReadOnly}
autoComplete='off' autoComplete='off'
className={cn( className={cn(
'allow-scroll w-full overflow-auto text-transparent caret-foreground', 'allow-scroll w-full overflow-x-auto overflow-y-hidden text-transparent caret-foreground',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2' dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)} )}
style={{ overflowX: 'auto' }}
onDrop={(e) => handleDrop(e, assignment.id)} onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)} onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)} onDragLeave={(e) => handleDragLeave(e, assignment.id)}
@@ -465,7 +511,7 @@ export function VariablesInput({
if (el) overlayRefs.current[assignment.id] = el if (el) overlayRefs.current[assignment.id] = el
}} }}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm' className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
style={{ overflowX: 'auto' }} style={{ scrollbarWidth: 'none' }}
> >
<div <div
className='w-full whitespace-pre' className='w-full whitespace-pre'

View File

@@ -284,22 +284,37 @@ const renderLabel = (
</> </>
)} )}
{showCanonicalToggle && ( {showCanonicalToggle && (
<button <Tooltip.Root>
type='button' <Tooltip.Trigger asChild>
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50' <button
onClick={canonicalToggle?.onToggle} type='button'
disabled={canonicalToggleDisabledResolved} className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
aria-label={canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'} onClick={canonicalToggle?.onToggle}
> disabled={canonicalToggleDisabledResolved}
<ArrowLeftRight aria-label={
className={cn( canonicalToggle?.mode === 'advanced'
'!h-[12px] !w-[12px]', ? 'Switch to selector'
canonicalToggle?.mode === 'advanced' : 'Switch to manual ID'
? 'text-[var(--text-primary)]' }
: 'text-[var(--text-secondary)]' >
)} <ArrowLeftRight
/> className={cn(
</button> '!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>
</div> </div>
@@ -323,6 +338,11 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
const configEqual = const configEqual =
prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type
const canonicalToggleEqual =
!!prevProps.canonicalToggle === !!nextProps.canonicalToggle &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
return ( return (
prevProps.blockId === nextProps.blockId && prevProps.blockId === nextProps.blockId &&
configEqual && configEqual &&
@@ -331,8 +351,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.disabled === nextProps.disabled && prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus && prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview && prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode && canonicalToggleEqual
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
) )
} }

View File

@@ -13,7 +13,11 @@ interface UseCanvasContextMenuProps {
/** /**
* Hook for managing workflow canvas context menus. * 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) { export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasContextMenuProps) {
const [activeMenu, setActiveMenu] = useState<MenuType>(null) const [activeMenu, setActiveMenu] = useState<MenuType>(null)
@@ -46,19 +50,29 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
event.stopPropagation() event.stopPropagation()
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) => const currentSelectedNodes = getNodes().filter((n) => n.selected)
nodes.map((n) => ({ const isClickedNodeSelected = currentSelectedNodes.some((n) => n.id === node.id)
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
}))
)
const selectedNodes = getNodes().filter((n) => n.selected) let nodesToUse: Node[]
const nodesToUse = isMultiSelect if (isClickedNodeSelected) {
? selectedNodes.some((n) => n.id === node.id) nodesToUse = currentSelectedNodes
? selectedNodes } else if (isMultiSelect) {
: [...selectedNodes, node] nodesToUse = [...currentSelectedNodes, node]
: [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 }) setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse)) setSelectedBlocks(nodesToBlockInfos(nodesToUse))

View File

@@ -27,18 +27,13 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 }) const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
// Used to prevent click-outside dismissal when trigger is clicked
const dismissPreventedRef = useRef(false) const dismissPreventedRef = useRef(false)
/**
* Handle right-click event
*/
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
// Calculate position relative to viewport
const x = e.clientX const x = e.clientX
const y = e.clientY const y = e.clientY
@@ -50,17 +45,10 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
[onContextMenu] [onContextMenu]
) )
/**
* Close the context menu
*/
const closeMenu = useCallback(() => { const closeMenu = useCallback(() => {
setIsOpen(false) 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(() => { const preventDismiss = useCallback(() => {
dismissPreventedRef.current = true dismissPreventedRef.current = true
}, []) }, [])
@@ -72,7 +60,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
if (!isOpen) return if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
// Check if dismissal was prevented (e.g., by toggle trigger's pointerdown)
if (dismissPreventedRef.current) { if (dismissPreventedRef.current) {
dismissPreventedRef.current = false dismissPreventedRef.current = false
return 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(() => { const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
}, 0) }, 0)

View File

@@ -214,15 +214,6 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
], ],
config: { config: {
tool: (params) => params.operation as string, tool: (params) => params.operation as string,
params: (params) => {
const { fileUpload, fileReference, ...rest } = params
const hasFileUpload = Array.isArray(fileUpload) ? fileUpload.length > 0 : !!fileUpload
const files = hasFileUpload ? fileUpload : fileReference
return {
...rest,
...(files ? { files } : {}),
}
},
}, },
}, },
inputs: { inputs: {

View File

@@ -581,6 +581,18 @@ export const GmailV2Block: BlockConfig<GmailToolResponse> = {
results: { type: 'json', description: 'Search/read summary results' }, results: { type: 'json', description: 'Search/read summary results' },
attachments: { type: 'json', description: 'Downloaded attachments (if enabled)' }, attachments: { type: 'json', description: 'Downloaded attachments (if enabled)' },
// Draft-specific outputs
draftId: {
type: 'string',
description: 'Draft ID',
condition: { field: 'operation', value: 'draft_gmail' },
},
messageId: {
type: 'string',
description: 'Gmail message ID for the draft',
condition: { field: 'operation', value: 'draft_gmail' },
},
// Trigger outputs (unchanged) // Trigger outputs (unchanged)
email_id: { type: 'string', description: 'Gmail message ID' }, email_id: { type: 'string', description: 'Gmail message ID' },
thread_id: { type: 'string', description: 'Gmail thread ID' }, thread_id: { type: 'string', description: 'Gmail thread ID' },

View File

@@ -7,12 +7,12 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
type: 'spotify', type: 'spotify',
name: 'Spotify', name: 'Spotify',
description: 'Search music, manage playlists, control playback, and access your library', description: 'Search music, manage playlists, control playback, and access your library',
hideFromToolbar: true,
authMode: AuthMode.OAuth, authMode: AuthMode.OAuth,
longDescription: longDescription:
'Integrate Spotify into your workflow. Search for tracks, albums, artists, and playlists. Manage playlists, access your library, control playback, browse podcasts and audiobooks.', 'Integrate Spotify into your workflow. Search for tracks, albums, artists, and playlists. Manage playlists, access your library, control playback, browse podcasts and audiobooks.',
docsLink: 'https://docs.sim.ai/tools/spotify', docsLink: 'https://docs.sim.ai/tools/spotify',
category: 'tools', category: 'tools',
hideFromToolbar: true,
bgColor: '#000000', bgColor: '#000000',
icon: SpotifyIcon, icon: SpotifyIcon,
subBlocks: [ subBlocks: [

View File

@@ -661,12 +661,25 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
placeholder: 'folder/subfolder/', placeholder: 'folder/subfolder/',
condition: { field: 'operation', value: 'storage_upload' }, condition: { field: 'operation', value: 'storage_upload' },
}, },
{
id: 'file',
title: 'File',
type: 'file-upload',
canonicalParamId: 'fileData',
placeholder: 'Upload file to storage',
condition: { field: 'operation', value: 'storage_upload' },
mode: 'basic',
multiple: false,
required: true,
},
{ {
id: 'fileContent', id: 'fileContent',
title: 'File Content', title: 'File Content',
type: 'code', type: 'code',
canonicalParamId: 'fileData',
placeholder: 'Base64 encoded for binary files, or plain text', placeholder: 'Base64 encoded for binary files, or plain text',
condition: { field: 'operation', value: 'storage_upload' }, condition: { field: 'operation', value: 'storage_upload' },
mode: 'advanced',
required: true, required: true,
}, },
{ {

View File

@@ -4,9 +4,9 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import { BlockType, DEFAULTS, EVALUATOR, HTTP } from '@/executor/constants' import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, extractAPIErrorMessage } from '@/executor/utils/http' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { validateModelProvider } from '@/executor/utils/permission-check' import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils' import { calculateCost, getProviderFromModel } from '@/providers/utils'
@@ -143,9 +143,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: 'POST', method: 'POST',
headers: { headers: await buildAuthHeaders(),
'Content-Type': HTTP.CONTENT_TYPE.JSON,
},
body: stringifyJSON(providerRequest), body: stringifyJSON(providerRequest),
}) })

View File

@@ -9,12 +9,12 @@ import type { BlockOutput } from '@/blocks/types'
import { import {
BlockType, BlockType,
DEFAULTS, DEFAULTS,
HTTP,
isAgentBlockType, isAgentBlockType,
isRouterV2BlockType, isRouterV2BlockType,
ROUTER, ROUTER,
} from '@/executor/constants' } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { validateModelProvider } from '@/executor/utils/permission-check' import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils' import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
@@ -118,9 +118,7 @@ export class RouterBlockHandler implements BlockHandler {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: 'POST', method: 'POST',
headers: { headers: await buildAuthHeaders(),
'Content-Type': HTTP.CONTENT_TYPE.JSON,
},
body: JSON.stringify(providerRequest), body: JSON.stringify(providerRequest),
}) })
@@ -277,9 +275,7 @@ export class RouterBlockHandler implements BlockHandler {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: 'POST', method: 'POST',
headers: { headers: await buildAuthHeaders(),
'Content-Type': HTTP.CONTENT_TYPE.JSON,
},
body: JSON.stringify(providerRequest), body: JSON.stringify(providerRequest),
}) })

View File

@@ -1,7 +1,9 @@
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { normalizeName } from '@/executor/constants' import { normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types' import type { ExecutionContext } from '@/executor/types'
import type { OutputSchema } from '@/executor/utils/block-reference' import type { OutputSchema } from '@/executor/utils/block-reference'
import type { SerializedBlock } from '@/serializer/types'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
export interface BlockDataCollection { export interface BlockDataCollection {
blockData: Record<string, unknown> blockData: Record<string, unknown>
@@ -9,6 +11,32 @@ export interface BlockDataCollection {
blockOutputSchemas: Record<string, OutputSchema> blockOutputSchemas: Record<string, OutputSchema>
} }
export function getBlockSchema(
block: SerializedBlock,
toolConfig?: ToolConfig
): OutputSchema | undefined {
const isTrigger =
block.metadata?.category === 'triggers' ||
(block.config?.params as Record<string, unknown> | undefined)?.triggerMode === true
// Triggers use saved outputs (defines the trigger payload schema)
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}
// When a tool is selected, tool outputs are the source of truth
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
return toolConfig.outputs as OutputSchema
}
// Fallback to saved outputs for blocks without tools
if (block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}
return undefined
}
export function collectBlockData(ctx: ExecutionContext): BlockDataCollection { export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
const blockData: Record<string, unknown> = {} const blockData: Record<string, unknown> = {}
const blockNameMapping: Record<string, string> = {} const blockNameMapping: Record<string, string> = {}
@@ -18,24 +46,21 @@ export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
if (state.output !== undefined) { if (state.output !== undefined) {
blockData[id] = state.output blockData[id] = state.output
} }
}
const workflowBlock = ctx.workflow?.blocks?.find((b) => b.id === id) const workflowBlocks = ctx.workflow?.blocks ?? []
if (!workflowBlock) continue for (const block of workflowBlocks) {
const id = block.id
if (workflowBlock.metadata?.name) { if (block.metadata?.name) {
blockNameMapping[normalizeName(workflowBlock.metadata.name)] = id blockNameMapping[normalizeName(block.metadata.name)] = id
} }
const blockType = workflowBlock.metadata?.id const toolId = block.config?.tool
if (blockType) { const toolConfig = toolId ? getTool(toolId) : undefined
const params = workflowBlock.config?.params as Record<string, unknown> | undefined const schema = getBlockSchema(block, toolConfig)
const subBlocks = params if (schema && Object.keys(schema).length > 0) {
? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }])) blockOutputSchemas[id] = schema
: undefined
const schema = getBlockOutputs(blockType, subBlocks)
if (schema && Object.keys(schema).length > 0) {
blockOutputSchemas[id] = schema
}
} }
} }

View File

@@ -378,8 +378,30 @@ function buildManualTriggerOutput(
return mergeFilesIntoOutput(output, workflowInput) return mergeFilesIntoOutput(output, workflowInput)
} }
function buildIntegrationTriggerOutput(workflowInput: unknown): NormalizedBlockOutput { function buildIntegrationTriggerOutput(
return isPlainObject(workflowInput) ? (workflowInput as NormalizedBlockOutput) : {} workflowInput: unknown,
structuredInput: Record<string, unknown>,
hasStructured: boolean
): NormalizedBlockOutput {
const output: NormalizedBlockOutput = {}
if (hasStructured) {
for (const [key, value] of Object.entries(structuredInput)) {
output[key] = value
}
}
if (isPlainObject(workflowInput)) {
for (const [key, value] of Object.entries(workflowInput)) {
if (value !== undefined && value !== null) {
output[key] = value
} else if (!Object.hasOwn(output, key)) {
output[key] = value
}
}
}
return mergeFilesIntoOutput(output, workflowInput)
} }
function extractSubBlocks(block: SerializedBlock): Record<string, unknown> | undefined { function extractSubBlocks(block: SerializedBlock): Record<string, unknown> | undefined {
@@ -428,7 +450,7 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali
return buildManualTriggerOutput(finalInput, workflowInput) return buildManualTriggerOutput(finalInput, workflowInput)
case StartBlockPath.EXTERNAL_TRIGGER: case StartBlockPath.EXTERNAL_TRIGGER:
return buildIntegrationTriggerOutput(workflowInput) return buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
case StartBlockPath.LEGACY_STARTER: case StartBlockPath.LEGACY_STARTER:
return buildLegacyStarterOutput( return buildLegacyStarterOutput(

View File

@@ -1,10 +1,10 @@
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { import {
isReference, isReference,
normalizeName, normalizeName,
parseReferencePath, parseReferencePath,
SPECIAL_REFERENCE_PREFIXES, SPECIAL_REFERENCE_PREFIXES,
} from '@/executor/constants' } from '@/executor/constants'
import { getBlockSchema } from '@/executor/utils/block-data'
import { import {
InvalidFieldError, InvalidFieldError,
type OutputSchema, type OutputSchema,
@@ -67,15 +67,9 @@ export class BlockResolver implements Resolver {
blockData[blockId] = output blockData[blockId] = output
} }
const blockType = block.metadata?.id
const params = block.config?.params as Record<string, unknown> | undefined
const subBlocks = params
? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }]))
: undefined
const toolId = block.config?.tool const toolId = block.config?.tool
const toolConfig = toolId ? getTool(toolId) : undefined const toolConfig = toolId ? getTool(toolId) : undefined
const outputSchema = const outputSchema = getBlockSchema(block, toolConfig)
toolConfig?.outputs ?? (blockType ? getBlockOutputs(blockType, subBlocks) : block.outputs)
if (outputSchema && Object.keys(outputSchema).length > 0) { if (outputSchema && Object.keys(outputSchema).length > 0) {
blockOutputSchemas[blockId] = outputSchema blockOutputSchemas[blockId] = outputSchema

View File

@@ -24,7 +24,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, mergeSubblockState } from '@/stores/workflows/utils' import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types' import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
@@ -226,9 +226,12 @@ export function useCollaborativeWorkflow() {
case EDGES_OPERATIONS.BATCH_ADD_EDGES: { case EDGES_OPERATIONS.BATCH_ADD_EDGES: {
const { edges } = payload const { edges } = payload
if (Array.isArray(edges) && edges.length > 0) { if (Array.isArray(edges) && edges.length > 0) {
const newEdges = filterNewEdges(edges, useWorkflowStore.getState().edges) const blocks = useWorkflowStore.getState().blocks
const currentEdges = useWorkflowStore.getState().edges
const validEdges = filterValidEdges(edges, blocks)
const newEdges = filterNewEdges(validEdges, currentEdges)
if (newEdges.length > 0) { if (newEdges.length > 0) {
useWorkflowStore.getState().batchAddEdges(newEdges) useWorkflowStore.getState().batchAddEdges(newEdges, { skipValidation: true })
} }
} }
break break
@@ -677,6 +680,10 @@ export function useCollaborativeWorkflow() {
previousPositions?: Map<string, { x: number; y: number; parentId?: string }> previousPositions?: Map<string, { x: number; y: number; parentId?: string }>
} }
) => { ) => {
if (isBaselineDiffView) {
return
}
if (!isInActiveRoom()) { if (!isInActiveRoom()) {
logger.debug('Skipping batch position update - not in active workflow') logger.debug('Skipping batch position update - not in active workflow')
return return
@@ -722,7 +729,7 @@ export function useCollaborativeWorkflow() {
} }
} }
}, },
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo] [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo]
) )
const collaborativeUpdateBlockName = useCallback( const collaborativeUpdateBlockName = useCallback(
@@ -814,6 +821,10 @@ export function useCollaborativeWorkflow() {
const collaborativeBatchToggleBlockEnabled = useCallback( const collaborativeBatchToggleBlockEnabled = useCallback(
(ids: string[]) => { (ids: string[]) => {
if (isBaselineDiffView) {
return
}
if (ids.length === 0) return if (ids.length === 0) return
const previousStates: Record<string, boolean> = {} const previousStates: Record<string, boolean> = {}
@@ -846,7 +857,7 @@ export function useCollaborativeWorkflow() {
undoRedo.recordBatchToggleEnabled(validIds, previousStates) undoRedo.recordBatchToggleEnabled(validIds, previousStates)
}, },
[addToQueue, activeWorkflowId, session?.user?.id, undoRedo] [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
) )
const collaborativeBatchUpdateParent = useCallback( const collaborativeBatchUpdateParent = useCallback(
@@ -858,6 +869,10 @@ export function useCollaborativeWorkflow() {
affectedEdges: Edge[] affectedEdges: Edge[]
}> }>
) => { ) => {
if (isBaselineDiffView) {
return
}
if (!isInActiveRoom()) { if (!isInActiveRoom()) {
logger.debug('Skipping batch update parent - not in active workflow') logger.debug('Skipping batch update parent - not in active workflow')
return return
@@ -928,7 +943,7 @@ export function useCollaborativeWorkflow() {
logger.debug('Batch updated parent for blocks', { updateCount: updates.length }) logger.debug('Batch updated parent for blocks', { updateCount: updates.length })
}, },
[isInActiveRoom, undoRedo, addToQueue, activeWorkflowId, session?.user?.id] [isBaselineDiffView, isInActiveRoom, undoRedo, addToQueue, activeWorkflowId, session?.user?.id]
) )
const collaborativeToggleBlockAdvancedMode = useCallback( const collaborativeToggleBlockAdvancedMode = useCallback(
@@ -948,18 +963,37 @@ export function useCollaborativeWorkflow() {
const collaborativeSetBlockCanonicalMode = useCallback( const collaborativeSetBlockCanonicalMode = useCallback(
(id: string, canonicalId: string, canonicalMode: 'basic' | 'advanced') => { (id: string, canonicalId: string, canonicalMode: 'basic' | 'advanced') => {
executeQueuedOperation( if (isBaselineDiffView) {
BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, return
OPERATION_TARGETS.BLOCK, }
{ id, canonicalId, canonicalMode },
() => useWorkflowStore.getState().setBlockCanonicalMode(id, canonicalId, canonicalMode) useWorkflowStore.getState().setBlockCanonicalMode(id, canonicalId, canonicalMode)
)
if (!activeWorkflowId) {
return
}
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
target: OPERATION_TARGETS.BLOCK,
payload: { id, canonicalId, canonicalMode },
},
workflowId: activeWorkflowId,
userId: session?.user?.id || 'unknown',
})
}, },
[executeQueuedOperation] [isBaselineDiffView, activeWorkflowId, addToQueue, session?.user?.id]
) )
const collaborativeBatchToggleBlockHandles = useCallback( const collaborativeBatchToggleBlockHandles = useCallback(
(ids: string[]) => { (ids: string[]) => {
if (isBaselineDiffView) {
return
}
if (ids.length === 0) return if (ids.length === 0) return
const previousStates: Record<string, boolean> = {} const previousStates: Record<string, boolean> = {}
@@ -992,11 +1026,15 @@ export function useCollaborativeWorkflow() {
undoRedo.recordBatchToggleHandles(validIds, previousStates) undoRedo.recordBatchToggleHandles(validIds, previousStates)
}, },
[addToQueue, activeWorkflowId, session?.user?.id, undoRedo] [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
) )
const collaborativeBatchAddEdges = useCallback( const collaborativeBatchAddEdges = useCallback(
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => { (edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
if (isBaselineDiffView) {
return false
}
if (!isInActiveRoom()) { if (!isInActiveRoom()) {
logger.debug('Skipping batch add edges - not in active workflow') logger.debug('Skipping batch add edges - not in active workflow')
return false return false
@@ -1004,7 +1042,11 @@ export function useCollaborativeWorkflow() {
if (edges.length === 0) return false if (edges.length === 0) return false
const newEdges = filterNewEdges(edges, useWorkflowStore.getState().edges) // Filter out invalid edges (e.g., edges targeting trigger blocks) and duplicates
const blocks = useWorkflowStore.getState().blocks
const currentEdges = useWorkflowStore.getState().edges
const validEdges = filterValidEdges(edges, blocks)
const newEdges = filterNewEdges(validEdges, currentEdges)
if (newEdges.length === 0) return false if (newEdges.length === 0) return false
const operationId = crypto.randomUUID() const operationId = crypto.randomUUID()
@@ -1020,7 +1062,7 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown', userId: session?.user?.id || 'unknown',
}) })
useWorkflowStore.getState().batchAddEdges(newEdges) useWorkflowStore.getState().batchAddEdges(newEdges, { skipValidation: true })
if (!options?.skipUndoRedo) { if (!options?.skipUndoRedo) {
newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id)) newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
@@ -1028,11 +1070,15 @@ export function useCollaborativeWorkflow() {
return true return true
}, },
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo] [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo]
) )
const collaborativeBatchRemoveEdges = useCallback( const collaborativeBatchRemoveEdges = useCallback(
(edgeIds: string[], options?: { skipUndoRedo?: boolean }) => { (edgeIds: string[], options?: { skipUndoRedo?: boolean }) => {
if (isBaselineDiffView) {
return false
}
if (!isInActiveRoom()) { if (!isInActiveRoom()) {
logger.debug('Skipping batch remove edges - not in active workflow') logger.debug('Skipping batch remove edges - not in active workflow')
return false return false
@@ -1082,7 +1128,7 @@ export function useCollaborativeWorkflow() {
logger.info('Batch removed edges', { count: validEdgeIds.length }) logger.info('Batch removed edges', { count: validEdgeIds.length })
return true return true
}, },
[isInActiveRoom, addToQueue, activeWorkflowId, session, undoRedo] [isBaselineDiffView, isInActiveRoom, addToQueue, activeWorkflowId, session, undoRedo]
) )
const collaborativeSetSubblockValue = useCallback( const collaborativeSetSubblockValue = useCallback(
@@ -1158,6 +1204,10 @@ export function useCollaborativeWorkflow() {
(blockId: string, subblockId: string, value: any) => { (blockId: string, subblockId: string, value: any) => {
if (isApplyingRemoteChange.current) return if (isApplyingRemoteChange.current) return
if (isBaselineDiffView) {
return
}
if (!isInActiveRoom()) { if (!isInActiveRoom()) {
logger.debug('Skipping tag selection - not in active workflow', { logger.debug('Skipping tag selection - not in active workflow', {
currentWorkflowId, currentWorkflowId,
@@ -1185,7 +1235,14 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown', userId: session?.user?.id || 'unknown',
}) })
}, },
[addToQueue, currentWorkflowId, activeWorkflowId, session?.user?.id, isInActiveRoom] [
isBaselineDiffView,
addToQueue,
currentWorkflowId,
activeWorkflowId,
session?.user?.id,
isInActiveRoom,
]
) )
const collaborativeUpdateLoopType = useCallback( const collaborativeUpdateLoopType = useCallback(
@@ -1484,9 +1541,23 @@ export function useCollaborativeWorkflow() {
if (blocks.length === 0) return false if (blocks.length === 0) return false
// Filter out invalid edges (e.g., edges targeting trigger blocks)
// Combine existing blocks with new blocks for validation
const existingBlocks = useWorkflowStore.getState().blocks
const newBlocksMap = blocks.reduce(
(acc, block) => {
acc[block.id] = block
return acc
},
{} as Record<string, BlockState>
)
const allBlocks = { ...existingBlocks, ...newBlocksMap }
const validEdges = filterValidEdges(edges, allBlocks)
logger.info('Batch adding blocks collaboratively', { logger.info('Batch adding blocks collaboratively', {
blockCount: blocks.length, blockCount: blocks.length,
edgeCount: edges.length, edgeCount: validEdges.length,
filteredEdges: edges.length - validEdges.length,
}) })
const operationId = crypto.randomUUID() const operationId = crypto.randomUUID()
@@ -1496,16 +1567,18 @@ export function useCollaborativeWorkflow() {
operation: { operation: {
operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS,
target: OPERATION_TARGETS.BLOCKS, target: OPERATION_TARGETS.BLOCKS,
payload: { blocks, edges, loops, parallels, subBlockValues }, payload: { blocks, edges: validEdges, loops, parallels, subBlockValues },
}, },
workflowId: activeWorkflowId || '', workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown', userId: session?.user?.id || 'unknown',
}) })
useWorkflowStore.getState().batchAddBlocks(blocks, edges, subBlockValues) useWorkflowStore.getState().batchAddBlocks(blocks, validEdges, subBlockValues, {
skipEdgeValidation: true,
})
if (!options?.skipUndoRedo) { if (!options?.skipUndoRedo) {
undoRedo.recordBatchAddBlocks(blocks, edges, subBlockValues) undoRedo.recordBatchAddBlocks(blocks, validEdges, subBlockValues)
} }
return true return true
@@ -1515,6 +1588,10 @@ export function useCollaborativeWorkflow() {
const collaborativeBatchRemoveBlocks = useCallback( const collaborativeBatchRemoveBlocks = useCallback(
(blockIds: string[], options?: { skipUndoRedo?: boolean }) => { (blockIds: string[], options?: { skipUndoRedo?: boolean }) => {
if (isBaselineDiffView) {
return false
}
if (!isInActiveRoom()) { if (!isInActiveRoom()) {
logger.debug('Skipping batch remove blocks - not in active workflow') logger.debug('Skipping batch remove blocks - not in active workflow')
return false return false
@@ -1596,6 +1673,7 @@ export function useCollaborativeWorkflow() {
return true return true
}, },
[ [
isBaselineDiffView,
addToQueue, addToQueue,
activeWorkflowId, activeWorkflowId,
session?.user?.id, session?.user?.id,

View File

@@ -11,9 +11,10 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry' 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 { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
@@ -667,11 +668,47 @@ function createBlockFromParams(
} }
} }
}) })
if (validatedInputs) {
updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig)
}
} }
return blockState 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 * Normalize tools array by adding back fields that were sanitized for training
*/ */
@@ -1654,6 +1691,15 @@ function applyOperationsToWorkflowState(
block.data.collection = params.inputs.collection block.data.collection = params.inputs.collection
} }
} }
const editBlockConfig = getBlock(block.type)
if (editBlockConfig) {
updateCanonicalModesForInputs(
block,
Object.keys(validationResult.validInputs),
editBlockConfig
)
}
} }
// Update basic properties // Update basic properties
@@ -2256,6 +2302,15 @@ function applyOperationsToWorkflowState(
existingBlock.subBlocks[key].value = sanitizedValue existingBlock.subBlocks[key].value = sanitizedValue
} }
}) })
const existingBlockConfig = getBlock(existingBlock.type)
if (existingBlockConfig) {
updateCanonicalModesForInputs(
existingBlock,
Object.keys(validationResult.validInputs),
existingBlockConfig
)
}
} }
} else { } else {
// Special container types (loop, parallel) are not in the block registry but are valid // Special container types (loop, parallel) are not in the block registry but are valid

View File

@@ -8,6 +8,17 @@ const logger = createLogger('EmbeddingUtils')
const MAX_TOKENS_PER_REQUEST = 8000 const MAX_TOKENS_PER_REQUEST = 8000
const MAX_CONCURRENT_BATCHES = env.KB_CONFIG_CONCURRENCY_LIMIT || 50 const MAX_CONCURRENT_BATCHES = env.KB_CONFIG_CONCURRENCY_LIMIT || 50
const EMBEDDING_DIMENSIONS = 1536
/**
* Check if the model supports custom dimensions.
* text-embedding-3-* models support the dimensions parameter.
* Checks for 'embedding-3' to handle Azure deployments with custom naming conventions.
*/
function supportsCustomDimensions(modelName: string): boolean {
const name = modelName.toLowerCase()
return name.includes('embedding-3') && !name.includes('ada')
}
export class EmbeddingAPIError extends Error { export class EmbeddingAPIError extends Error {
public status: number public status: number
@@ -93,15 +104,19 @@ async function getEmbeddingConfig(
async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Promise<number[][]> { async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Promise<number[][]> {
return retryWithExponentialBackoff( return retryWithExponentialBackoff(
async () => { async () => {
const useDimensions = supportsCustomDimensions(config.modelName)
const requestBody = config.useAzure const requestBody = config.useAzure
? { ? {
input: inputs, input: inputs,
encoding_format: 'float', encoding_format: 'float',
...(useDimensions && { dimensions: EMBEDDING_DIMENSIONS }),
} }
: { : {
input: inputs, input: inputs,
model: config.modelName, model: config.modelName,
encoding_format: 'float', encoding_format: 'float',
...(useDimensions && { dimensions: EMBEDDING_DIMENSIONS }),
} }
const response = await fetch(config.apiUrl, { const response = await fetch(config.apiUrl, {

View File

@@ -18,6 +18,52 @@ const logger = createLogger('BlobClient')
let _blobServiceClient: BlobServiceClientInstance | null = null let _blobServiceClient: BlobServiceClientInstance | null = null
interface ParsedCredentials {
accountName: string
accountKey: string
}
/**
* Extract account name and key from an Azure connection string.
* Connection strings have the format: DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=...
*/
function parseConnectionString(connectionString: string): ParsedCredentials {
const accountNameMatch = connectionString.match(/AccountName=([^;]+)/)
if (!accountNameMatch) {
throw new Error('Cannot extract account name from connection string')
}
const accountKeyMatch = connectionString.match(/AccountKey=([^;]+)/)
if (!accountKeyMatch) {
throw new Error('Cannot extract account key from connection string')
}
return {
accountName: accountNameMatch[1],
accountKey: accountKeyMatch[1],
}
}
/**
* Get account credentials from BLOB_CONFIG, extracting from connection string if necessary.
*/
function getAccountCredentials(): ParsedCredentials {
if (BLOB_CONFIG.connectionString) {
return parseConnectionString(BLOB_CONFIG.connectionString)
}
if (BLOB_CONFIG.accountName && BLOB_CONFIG.accountKey) {
return {
accountName: BLOB_CONFIG.accountName,
accountKey: BLOB_CONFIG.accountKey,
}
}
throw new Error(
'Azure Blob Storage credentials are missing set AZURE_CONNECTION_STRING or both AZURE_ACCOUNT_NAME and AZURE_ACCOUNT_KEY'
)
}
export async function getBlobServiceClient(): Promise<BlobServiceClientInstance> { export async function getBlobServiceClient(): Promise<BlobServiceClientInstance> {
if (_blobServiceClient) return _blobServiceClient if (_blobServiceClient) return _blobServiceClient
@@ -127,6 +173,8 @@ export async function getPresignedUrl(key: string, expiresIn = 3600) {
const containerClient = blobServiceClient.getContainerClient(BLOB_CONFIG.containerName) const containerClient = blobServiceClient.getContainerClient(BLOB_CONFIG.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key) const blockBlobClient = containerClient.getBlockBlobClient(key)
const { accountName, accountKey } = getAccountCredentials()
const sasOptions = { const sasOptions = {
containerName: BLOB_CONFIG.containerName, containerName: BLOB_CONFIG.containerName,
blobName: key, blobName: key,
@@ -137,13 +185,7 @@ export async function getPresignedUrl(key: string, expiresIn = 3600) {
const sasToken = generateBlobSASQueryParameters( const sasToken = generateBlobSASQueryParameters(
sasOptions, sasOptions,
new StorageSharedKeyCredential( new StorageSharedKeyCredential(accountName, accountKey)
BLOB_CONFIG.accountName,
BLOB_CONFIG.accountKey ??
(() => {
throw new Error('AZURE_ACCOUNT_KEY is required when using account name authentication')
})()
)
).toString() ).toString()
return `${blockBlobClient.url}?${sasToken}` return `${blockBlobClient.url}?${sasToken}`
@@ -168,9 +210,14 @@ export async function getPresignedUrlWithConfig(
StorageSharedKeyCredential, StorageSharedKeyCredential,
} = await import('@azure/storage-blob') } = await import('@azure/storage-blob')
let tempBlobServiceClient: BlobServiceClientInstance let tempBlobServiceClient: BlobServiceClientInstance
let accountName: string
let accountKey: string
if (customConfig.connectionString) { if (customConfig.connectionString) {
tempBlobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) tempBlobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
const credentials = parseConnectionString(customConfig.connectionString)
accountName = credentials.accountName
accountKey = credentials.accountKey
} else if (customConfig.accountName && customConfig.accountKey) { } else if (customConfig.accountName && customConfig.accountKey) {
const sharedKeyCredential = new StorageSharedKeyCredential( const sharedKeyCredential = new StorageSharedKeyCredential(
customConfig.accountName, customConfig.accountName,
@@ -180,6 +227,8 @@ export async function getPresignedUrlWithConfig(
`https://${customConfig.accountName}.blob.core.windows.net`, `https://${customConfig.accountName}.blob.core.windows.net`,
sharedKeyCredential sharedKeyCredential
) )
accountName = customConfig.accountName
accountKey = customConfig.accountKey
} else { } else {
throw new Error( throw new Error(
'Custom blob config must include either connectionString or accountName + accountKey' 'Custom blob config must include either connectionString or accountName + accountKey'
@@ -199,13 +248,7 @@ export async function getPresignedUrlWithConfig(
const sasToken = generateBlobSASQueryParameters( const sasToken = generateBlobSASQueryParameters(
sasOptions, sasOptions,
new StorageSharedKeyCredential( new StorageSharedKeyCredential(accountName, accountKey)
customConfig.accountName,
customConfig.accountKey ??
(() => {
throw new Error('Account key is required when using account name authentication')
})()
)
).toString() ).toString()
return `${blockBlobClient.url}?${sasToken}` return `${blockBlobClient.url}?${sasToken}`
@@ -403,13 +446,9 @@ export async function getMultipartPartUrls(
if (customConfig) { if (customConfig) {
if (customConfig.connectionString) { if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
const match = customConfig.connectionString.match(/AccountName=([^;]+)/) const credentials = parseConnectionString(customConfig.connectionString)
if (!match) throw new Error('Cannot extract account name from connection string') accountName = credentials.accountName
accountName = match[1] accountKey = credentials.accountKey
const keyMatch = customConfig.connectionString.match(/AccountKey=([^;]+)/)
if (!keyMatch) throw new Error('Cannot extract account key from connection string')
accountKey = keyMatch[1]
} else if (customConfig.accountName && customConfig.accountKey) { } else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential( const credential = new StorageSharedKeyCredential(
customConfig.accountName, customConfig.accountName,
@@ -428,12 +467,9 @@ export async function getMultipartPartUrls(
} else { } else {
blobServiceClient = await getBlobServiceClient() blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName containerName = BLOB_CONFIG.containerName
accountName = BLOB_CONFIG.accountName const credentials = getAccountCredentials()
accountKey = accountName = credentials.accountName
BLOB_CONFIG.accountKey || accountKey = credentials.accountKey
(() => {
throw new Error('AZURE_ACCOUNT_KEY is required')
})()
} }
const containerClient = blobServiceClient.getContainerClient(containerName) const containerClient = blobServiceClient.getContainerClient(containerName)
@@ -501,12 +537,10 @@ export async function completeMultipartUpload(
const containerClient = blobServiceClient.getContainerClient(containerName) const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key) const blockBlobClient = containerClient.getBlockBlobClient(key)
// Sort parts by part number and extract block IDs
const sortedBlockIds = parts const sortedBlockIds = parts
.sort((a, b) => a.partNumber - b.partNumber) .sort((a, b) => a.partNumber - b.partNumber)
.map((part) => part.blockId) .map((part) => part.blockId)
// Commit the block list to create the final blob
await blockBlobClient.commitBlockList(sortedBlockIds, { await blockBlobClient.commitBlockList(sortedBlockIds, {
metadata: { metadata: {
multipartUpload: 'completed', multipartUpload: 'completed',
@@ -557,10 +591,8 @@ export async function abortMultipartUpload(key: string, customConfig?: BlobConfi
const blockBlobClient = containerClient.getBlockBlobClient(key) const blockBlobClient = containerClient.getBlockBlobClient(key)
try { try {
// Delete the blob if it exists (this also cleans up any uncommitted blocks)
await blockBlobClient.deleteIfExists() await blockBlobClient.deleteIfExists()
} catch (error) { } catch (error) {
// Ignore errors since we're just cleaning up
logger.warn('Error cleaning up multipart upload:', error) logger.warn('Error cleaning up multipart upload:', error)
} }
} }

View File

@@ -618,13 +618,6 @@ export function getToolOutputs(
} }
} }
/**
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param subBlocks - SubBlock values for tool selection and condition evaluation
* @returns Array of output paths for the tool, or empty array on error
*/
export function getToolOutputPaths( export function getToolOutputPaths(
blockConfig: BlockConfig, blockConfig: BlockConfig,
subBlocks?: Record<string, SubBlockWithValue> subBlocks?: Record<string, SubBlockWithValue>
@@ -634,12 +627,22 @@ export function getToolOutputPaths(
if (!outputs || Object.keys(outputs).length === 0) return [] if (!outputs || Object.keys(outputs).length === 0) return []
if (subBlocks && blockConfig.outputs) { if (subBlocks && blockConfig.outputs) {
const filteredBlockOutputs = filterOutputsByCondition(blockConfig.outputs, subBlocks)
const allowedKeys = new Set(Object.keys(filteredBlockOutputs))
const filteredOutputs: Record<string, any> = {} const filteredOutputs: Record<string, any> = {}
for (const [key, value] of Object.entries(outputs)) { for (const [key, value] of Object.entries(outputs)) {
if (allowedKeys.has(key)) { const blockOutput = blockConfig.outputs[key]
if (!blockOutput || typeof blockOutput !== 'object') {
filteredOutputs[key] = value
continue
}
const condition = 'condition' in blockOutput ? blockOutput.condition : undefined
if (condition) {
if (evaluateOutputCondition(condition, subBlocks)) {
filteredOutputs[key] = value
}
} else {
filteredOutputs[key] = value filteredOutputs[key] = value
} }
} }

View File

@@ -24,7 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@a2a-js/sdk": "0.3.7", "@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "^0.39.0", "@anthropic-ai/sdk": "0.71.2",
"@aws-sdk/client-bedrock-runtime": "3.940.0", "@aws-sdk/client-bedrock-runtime": "3.940.0",
"@aws-sdk/client-dynamodb": "3.940.0", "@aws-sdk/client-dynamodb": "3.940.0",
"@aws-sdk/client-rds-data": "3.940.0", "@aws-sdk/client-rds-data": "3.940.0",

View File

@@ -1,4 +1,5 @@
import Anthropic from '@anthropic-ai/sdk' import Anthropic from '@anthropic-ai/sdk'
import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
@@ -185,13 +186,10 @@ export const anthropicProvider: ProviderConfig = {
const schema = request.responseFormat.schema || request.responseFormat const schema = request.responseFormat.schema || request.responseFormat
if (useNativeStructuredOutputs) { if (useNativeStructuredOutputs) {
const schemaWithConstraints = { const transformedSchema = transformJSONSchema(schema)
...schema,
additionalProperties: false,
}
payload.output_format = { payload.output_format = {
type: 'json_schema', type: 'json_schema',
schema: schemaWithConstraints, schema: transformedSchema,
} }
logger.info(`Using native structured outputs for model: ${modelId}`) logger.info(`Using native structured outputs for model: ${modelId}`)
} else { } else {

View File

@@ -27,6 +27,9 @@ export function registerEmitFunctions(
emitSubblockUpdate = subblockEmit emitSubblockUpdate = subblockEmit
emitVariableUpdate = variableEmit emitVariableUpdate = variableEmit
currentRegisteredWorkflowId = workflowId currentRegisteredWorkflowId = workflowId
if (workflowId) {
useOperationQueueStore.getState().processNextOperation()
}
} }
let currentRegisteredWorkflowId: string | null = null let currentRegisteredWorkflowId: string | null = null
@@ -262,16 +265,14 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
return return
} }
const nextOperation = currentRegisteredWorkflowId if (!currentRegisteredWorkflowId) {
? state.operations.find(
(op) => op.status === 'pending' && op.workflowId === currentRegisteredWorkflowId
)
: state.operations.find((op) => op.status === 'pending')
if (!nextOperation) {
return return
} }
if (currentRegisteredWorkflowId && nextOperation.workflowId !== currentRegisteredWorkflowId) { const nextOperation = state.operations.find(
(op) => op.status === 'pending' && op.workflowId === currentRegisteredWorkflowId
)
if (!nextOperation) {
return return
} }

View File

@@ -2,8 +2,9 @@ import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants' import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { import type {
BlockState, BlockState,
@@ -17,6 +18,32 @@ import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
/**
* Checks if an edge is valid (source and target exist, not annotation-only, target is not a trigger)
*/
function isValidEdge(
edge: Edge,
blocks: Record<string, { type: string; triggerMode?: boolean }>
): boolean {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) return false
if (isAnnotationOnlyBlock(sourceBlock.type)) return false
if (isAnnotationOnlyBlock(targetBlock.type)) return false
if (TriggerUtils.isTriggerBlock(targetBlock)) return false
return true
}
/**
* Filters edges to only include valid ones (target exists and is not a trigger block)
*/
export function filterValidEdges(
edges: Edge[],
blocks: Record<string, { type: string; triggerMode?: boolean }>
): Edge[] {
return edges.filter((edge) => isValidEdge(edge, blocks))
}
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] { export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => { return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false if (edge.source === edge.target) return false

View File

@@ -4,13 +4,17 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware' import { devtools } from 'zustand/middleware'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { isAnnotationOnlyBlock, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils' import {
filterNewEdges,
filterValidEdges,
getUniqueBlockName,
mergeSubblockState,
} from '@/stores/workflows/utils'
import type { import type {
Position, Position,
SubBlockState, SubBlockState,
@@ -91,26 +95,6 @@ function resolveInitialSubblockValue(config: SubBlockConfig): unknown {
return null return null
} }
function isValidEdge(
edge: Edge,
blocks: Record<string, { type: string; triggerMode?: boolean }>
): boolean {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) return false
if (isAnnotationOnlyBlock(sourceBlock.type)) return false
if (isAnnotationOnlyBlock(targetBlock.type)) return false
if (TriggerUtils.isTriggerBlock(targetBlock)) return false
return true
}
function filterValidEdges(
edges: Edge[],
blocks: Record<string, { type: string; triggerMode?: boolean }>
): Edge[] {
return edges.filter((edge) => isValidEdge(edge, blocks))
}
const initialState = { const initialState = {
blocks: {}, blocks: {},
edges: [], edges: [],
@@ -356,7 +340,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
data?: Record<string, any> data?: Record<string, any>
}>, }>,
edges?: Edge[], edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>> subBlockValues?: Record<string, Record<string, unknown>>,
options?: { skipEdgeValidation?: boolean }
) => { ) => {
const currentBlocks = get().blocks const currentBlocks = get().blocks
const currentEdges = get().edges const currentEdges = get().edges
@@ -381,7 +366,10 @@ export const useWorkflowStore = create<WorkflowStore>()(
} }
if (edges && edges.length > 0) { if (edges && edges.length > 0) {
const validEdges = filterValidEdges(edges, newBlocks) // Skip validation if already validated by caller (e.g., collaborative layer)
const validEdges = options?.skipEdgeValidation
? edges
: filterValidEdges(edges, newBlocks)
const existingEdgeIds = new Set(currentEdges.map((e) => e.id)) const existingEdgeIds = new Set(currentEdges.map((e) => e.id))
for (const edge of validEdges) { for (const edge of validEdges) {
if (!existingEdgeIds.has(edge.id)) { if (!existingEdgeIds.has(edge.id)) {
@@ -516,11 +504,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved() get().updateLastSaved()
}, },
batchAddEdges: (edges: Edge[]) => { batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => {
const blocks = get().blocks const blocks = get().blocks
const currentEdges = get().edges const currentEdges = get().edges
const validEdges = filterValidEdges(edges, blocks) // Skip validation if already validated by caller (e.g., collaborative layer)
const validEdges = options?.skipValidation ? edges : filterValidEdges(edges, blocks)
const filtered = filterNewEdges(validEdges, currentEdges) const filtered = filterNewEdges(validEdges, currentEdges)
const newEdges = [...currentEdges] const newEdges = [...currentEdges]

View File

@@ -203,12 +203,13 @@ export interface WorkflowActions {
batchAddBlocks: ( batchAddBlocks: (
blocks: BlockState[], blocks: BlockState[],
edges?: Edge[], edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>> subBlockValues?: Record<string, Record<string, unknown>>,
options?: { skipEdgeValidation?: boolean }
) => void ) => void
batchRemoveBlocks: (ids: string[]) => void batchRemoveBlocks: (ids: string[]) => void
batchToggleEnabled: (ids: string[]) => void batchToggleEnabled: (ids: string[]) => void
batchToggleHandles: (ids: string[]) => void batchToggleHandles: (ids: string[]) => void
batchAddEdges: (edges: Edge[]) => void batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => void
batchRemoveEdges: (ids: string[]) => void batchRemoveEdges: (ids: string[]) => void
clear: () => Partial<WorkflowState> clear: () => Partial<WorkflowState>
updateLastSaved: () => void updateLastSaved: () => void

View File

@@ -38,11 +38,12 @@ export const storageUploadTool: ToolConfig<
visibility: 'user-or-llm', visibility: 'user-or-llm',
description: 'Optional folder path (e.g., "folder/subfolder/")', description: 'Optional folder path (e.g., "folder/subfolder/")',
}, },
fileContent: { fileData: {
type: 'string', type: 'json',
required: true, required: true,
visibility: 'user-or-llm', visibility: 'user-or-llm',
description: 'The file content (base64 encoded for binary files, or plain text)', description:
'File to upload - UserFile object (basic mode) or string content (advanced mode: base64 or plain text). Supports data URLs.',
}, },
contentType: { contentType: {
type: 'string', type: 'string',
@@ -65,65 +66,28 @@ export const storageUploadTool: ToolConfig<
}, },
request: { request: {
url: (params) => { url: '/api/tools/supabase/storage-upload',
// Combine folder path and fileName, ensuring proper formatting
let fullPath = params.fileName
if (params.path) {
// Ensure path ends with / and doesn't have double slashes
const folderPath = params.path.endsWith('/') ? params.path : `${params.path}/`
fullPath = `${folderPath}${params.fileName}`
}
return `https://${params.projectId}.supabase.co/storage/v1/object/${params.bucket}/${fullPath}`
},
method: 'POST', method: 'POST',
headers: (params) => { headers: () => ({
const headers: Record<string, string> = { 'Content-Type': 'application/json',
apikey: params.apiKey, }),
Authorization: `Bearer ${params.apiKey}`, body: (params) => ({
} projectId: params.projectId,
apiKey: params.apiKey,
if (params.contentType) { bucket: params.bucket,
headers['Content-Type'] = params.contentType fileName: params.fileName,
} path: params.path,
fileData: params.fileData,
if (params.upsert) { contentType: params.contentType,
headers['x-upsert'] = 'true' upsert: params.upsert,
} }),
return headers
},
body: (params) => {
// Return the file content wrapped in an object
// The actual upload will need to handle this appropriately
return {
content: params.fileContent,
}
},
},
transformResponse: async (response: Response) => {
let data
try {
data = await response.json()
} catch (parseError) {
throw new Error(`Failed to parse Supabase storage upload response: ${parseError}`)
}
return {
success: true,
output: {
message: 'Successfully uploaded file to storage',
results: data,
},
error: undefined,
}
}, },
outputs: { outputs: {
message: { type: 'string', description: 'Operation status message' }, message: { type: 'string', description: 'Operation status message' },
results: { results: {
type: 'object', type: 'object',
description: 'Upload result including file path and metadata', description: 'Upload result including file path, bucket, and public URL',
}, },
}, },
} }

View File

@@ -136,7 +136,7 @@ export interface SupabaseStorageUploadParams {
bucket: string bucket: string
fileName: string fileName: string
path?: string path?: string
fileContent: string fileData: any // UserFile object (basic mode) or string (advanced mode: base64/plain text)
contentType?: string contentType?: string
upsert?: boolean upsert?: boolean
} }

View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "simstudio", "name": "simstudio",
@@ -55,7 +54,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@a2a-js/sdk": "0.3.7", "@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "^0.39.0", "@anthropic-ai/sdk": "0.71.2",
"@aws-sdk/client-bedrock-runtime": "3.940.0", "@aws-sdk/client-bedrock-runtime": "3.940.0",
"@aws-sdk/client-dynamodb": "3.940.0", "@aws-sdk/client-dynamodb": "3.940.0",
"@aws-sdk/client-rds-data": "3.940.0", "@aws-sdk/client-rds-data": "3.940.0",
@@ -363,7 +362,7 @@
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
@@ -547,6 +546,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
@@ -2443,6 +2444,8 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -3387,6 +3390,8 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsafe": ["tsafe@1.8.12", "", {}, "sha512-nFRqW0ttu/2o6XTXsHiVZWJBCOaxhVqZLg7dgs3coZNsCMPXPfwz+zPHAQA+70fNnVJLAPg1EgGIqK9Q84tvAw=="], "tsafe": ["tsafe@1.8.12", "", {}, "sha512-nFRqW0ttu/2o6XTXsHiVZWJBCOaxhVqZLg7dgs3coZNsCMPXPfwz+zPHAQA+70fNnVJLAPg1EgGIqK9Q84tvAw=="],
@@ -3593,10 +3598,6 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@anthropic-ai/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.969.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-7IIzM5TdiXn+VtgPdVLjmE6uUBUtnga0f4RiSEI1WW10RPuNvZ9U+pL3SwDiRDAdoGrOF9tSLJOFZmfuwYuVYQ=="], "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.969.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-7IIzM5TdiXn+VtgPdVLjmE6uUBUtnga0f4RiSEI1WW10RPuNvZ9U+pL3SwDiRDAdoGrOF9tSLJOFZmfuwYuVYQ=="],
@@ -3713,6 +3714,8 @@
"@browserbasehq/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "@browserbasehq/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
"@cerebras/cerebras_cloud_sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@cerebras/cerebras_cloud_sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@cerebras/cerebras_cloud_sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "@cerebras/cerebras_cloud_sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
@@ -4215,10 +4218,6 @@
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@anthropic-ai/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@@ -4275,6 +4274,10 @@
"@browserbasehq/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "@browserbasehq/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"@cerebras/cerebras_cloud_sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@cerebras/cerebras_cloud_sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -4685,10 +4688,6 @@
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@anthropic-ai/sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
@@ -4737,6 +4736,10 @@
"@browserbasehq/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "@browserbasehq/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
@@ -4829,6 +4832,10 @@
"@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.947.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.947.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.947.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.947.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.7", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.14", "@smithy/middleware-retry": "^4.4.14", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.13", "@smithy/util-defaults-mode-node": "^4.2.16", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DjRJEYNnHUTu9kGPPQDTSXquwSEd6myKR4ssI4FaYLFhdT3ldWpj73yYt807H3tdmhS7vPmdVqchSJnjurUQAw=="], "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.947.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.947.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.947.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.947.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.7", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.14", "@smithy/middleware-retry": "^4.4.14", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.13", "@smithy/util-defaults-mode-node": "^4.2.16", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DjRJEYNnHUTu9kGPPQDTSXquwSEd6myKR4ssI4FaYLFhdT3ldWpj73yYt807H3tdmhS7vPmdVqchSJnjurUQAw=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"lint-staged/listr2/cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "lint-staged/listr2/cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],

View File

@@ -52,7 +52,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 8G memory: 1G
healthcheck: healthcheck:
test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health'] test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health']
interval: 90s interval: 90s

View File

@@ -56,7 +56,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 8G memory: 1G
healthcheck: healthcheck:
test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health'] test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health']
interval: 90s interval: 90s

View File

@@ -42,7 +42,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 4G memory: 1G
environment: environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio} - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}

View File

@@ -10,13 +10,13 @@ global:
app: app:
enabled: true enabled: true
replicaCount: 2 replicaCount: 2
resources: resources:
limits: limits:
memory: "6Gi" memory: "8Gi"
cpu: "2000m" cpu: "2000m"
requests: requests:
memory: "4Gi" memory: "6Gi"
cpu: "1000m" cpu: "1000m"
# Production URLs (REQUIRED - update with your actual domain names) # Production URLs (REQUIRED - update with your actual domain names)
@@ -49,14 +49,14 @@ app:
realtime: realtime:
enabled: true enabled: true
replicaCount: 2 replicaCount: 2
resources: resources:
limits: limits:
memory: "4Gi" memory: "1Gi"
cpu: "1000m"
requests:
memory: "2Gi"
cpu: "500m" cpu: "500m"
requests:
memory: "512Mi"
cpu: "250m"
env: env:
NEXT_PUBLIC_APP_URL: "https://sim.acme.ai" NEXT_PUBLIC_APP_URL: "https://sim.acme.ai"

View File

@@ -29,10 +29,10 @@ app:
# Resource limits and requests # Resource limits and requests
resources: resources:
limits: limits:
memory: "4Gi" memory: "8Gi"
cpu: "2000m" cpu: "2000m"
requests: requests:
memory: "2Gi" memory: "4Gi"
cpu: "1000m" cpu: "1000m"
# Node selector for pod scheduling (leave empty to allow scheduling on any node) # Node selector for pod scheduling (leave empty to allow scheduling on any node)
@@ -232,24 +232,24 @@ app:
realtime: realtime:
# Enable/disable the realtime service # Enable/disable the realtime service
enabled: true enabled: true
# Image configuration # Image configuration
image: image:
repository: simstudioai/realtime repository: simstudioai/realtime
tag: latest tag: latest
pullPolicy: Always pullPolicy: Always
# Number of replicas # Number of replicas
replicaCount: 1 replicaCount: 1
# Resource limits and requests # Resource limits and requests
resources: resources:
limits: limits:
memory: "2Gi"
cpu: "1000m"
requests:
memory: "1Gi" memory: "1Gi"
cpu: "500m" cpu: "500m"
requests:
memory: "512Mi"
cpu: "250m"
# Node selector for pod scheduling (leave empty to allow scheduling on any node) # Node selector for pod scheduling (leave empty to allow scheduling on any node)
nodeSelector: {} nodeSelector: {}

View File

@@ -81,4 +81,7 @@ Thumbs.db
# mypy # mypy
.mypy_cache/ .mypy_cache/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# uv
uv.lock

View File

@@ -43,24 +43,30 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
#### Methods #### 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. Execute a workflow with optional input data.
```python ```python
result = client.execute_workflow( # With dict input (spread at root level of request body)
"workflow-id", result = client.execute_workflow("workflow-id", {"message": "Hello, world!"})
input_data={"message": "Hello, world!"},
timeout=30.0 # 30 seconds # 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:** **Parameters:**
- `workflow_id` (str): The ID of the workflow to execute - `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. - `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): Timeout in seconds (default: 30.0) - `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) ##### get_workflow_status(workflow_id)
@@ -92,24 +98,89 @@ if is_ready:
**Returns:** `bool` **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 ```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", "workflow-id",
input_data={"data": "some input"}, {"message": "Hello"},
timeout=60.0 timeout=30.0,
max_retries=3,
initial_delay=1.0,
max_delay=30.0,
backoff_multiplier=2.0
) )
``` ```
**Parameters:** **Parameters:**
- `workflow_id` (str): The ID of the workflow to execute - `workflow_id` (str): The ID of the workflow to execute
- `input_data` (dict, optional): Input data to pass to the workflow - `input` (any, optional): Input data to pass to the workflow
- `timeout` (float): Timeout for the initial request in seconds - `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) ##### set_api_key(api_key)
@@ -171,6 +242,39 @@ class SimStudioError(Exception):
self.status = status 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 ## Examples
### Basic Workflow Execution ### Basic Workflow Execution
@@ -191,7 +295,7 @@ def run_workflow():
# Execute the workflow # Execute the workflow
result = client.execute_workflow( result = client.execute_workflow(
"my-workflow-id", "my-workflow-id",
input_data={ {
"message": "Process this data", "message": "Process this data",
"user_id": "12345" "user_id": "12345"
} }
@@ -298,7 +402,7 @@ client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
with open('document.pdf', 'rb') as f: with open('document.pdf', 'rb') as f:
result = client.execute_workflow( result = client.execute_workflow(
'workflow-id', 'workflow-id',
input_data={ {
'documents': [f], # Must match your workflow's "files" field name 'documents': [f], # Must match your workflow's "files" field name
'instructions': 'Analyze this document' '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: with open('doc1.pdf', 'rb') as f1, open('doc2.pdf', 'rb') as f2:
result = client.execute_workflow( result = client.execute_workflow(
'workflow-id', 'workflow-id',
input_data={ {
'attachments': [f1, f2], # Must match your workflow's "files" field name 'attachments': [f1, f2], # Must match your workflow's "files" field name
'query': 'Compare these documents' 'query': 'Compare these documents'
} }
@@ -327,14 +431,14 @@ def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data.""" """Execute multiple workflows with different input data."""
results = [] results = []
for workflow_id, input_data in workflow_data_pairs: for workflow_id, workflow_input in workflow_data_pairs:
try: try:
# Validate workflow before execution # Validate workflow before execution
if not client.validate_workflow(workflow_id): if not client.validate_workflow(workflow_id):
print(f"Skipping {workflow_id}: not deployed") print(f"Skipping {workflow_id}: not deployed")
continue continue
result = client.execute_workflow(workflow_id, input_data) result = client.execute_workflow(workflow_id, workflow_input)
results.append({ results.append({
"workflow_id": workflow_id, "workflow_id": workflow_id,
"success": result.success, "success": result.success,

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "simstudio-sdk" name = "simstudio-sdk"
version = "0.1.1" version = "0.1.2"
authors = [ authors = [
{name = "Sim", email = "help@sim.ai"}, {name = "Sim", email = "help@sim.ai"},
] ]

View File

@@ -13,7 +13,7 @@ import os
import requests import requests
__version__ = "0.1.0" __version__ = "0.1.2"
__all__ = [ __all__ = [
"SimStudioClient", "SimStudioClient",
"SimStudioError", "SimStudioError",
@@ -64,15 +64,6 @@ class RateLimitInfo:
retry_after: Optional[int] = None 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 @dataclass
class UsageLimits: class UsageLimits:
"""Usage limits and quota information.""" """Usage limits and quota information."""
@@ -115,7 +106,6 @@ class SimStudioClient:
Recursively processes nested dicts and lists. Recursively processes nested dicts and lists.
""" """
import base64 import base64
import io
# Check if this is a file-like object # Check if this is a file-like object
if hasattr(value, 'read') and callable(value.read): if hasattr(value, 'read') and callable(value.read):
@@ -159,7 +149,8 @@ class SimStudioClient:
def execute_workflow( def execute_workflow(
self, self,
workflow_id: str, workflow_id: str,
input_data: Optional[Dict[str, Any]] = None, input: Optional[Any] = None,
*,
timeout: float = 30.0, timeout: float = 30.0,
stream: Optional[bool] = None, stream: Optional[bool] = None,
selected_outputs: Optional[list] = None, selected_outputs: Optional[list] = None,
@@ -169,11 +160,13 @@ class SimStudioClient:
Execute a workflow with optional input data. Execute a workflow with optional input data.
If async_execution is True, returns immediately with a task ID. 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: Args:
workflow_id: The ID of the workflow to execute 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) timeout: Timeout in seconds (default: 30.0)
stream: Enable streaming responses (default: None) stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"]) selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
@@ -193,8 +186,15 @@ class SimStudioClient:
headers['X-Execution-Mode'] = 'async' headers['X-Execution-Mode'] = 'async'
try: try:
# Build JSON body - spread input at root level, then add API control parameters # Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field
body = input_data.copy() if input_data is not None else {} 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 # Convert any file objects in the input to base64 format
body = self._convert_files_to_base64(body) body = self._convert_files_to_base64(body)
@@ -320,20 +320,18 @@ class SimStudioClient:
def execute_workflow_sync( def execute_workflow_sync(
self, self,
workflow_id: str, workflow_id: str,
input_data: Optional[Dict[str, Any]] = None, input: Optional[Any] = None,
*,
timeout: float = 30.0, timeout: float = 30.0,
stream: Optional[bool] = None, stream: Optional[bool] = None,
selected_outputs: Optional[list] = None selected_outputs: Optional[list] = None
) -> WorkflowExecutionResult: ) -> WorkflowExecutionResult:
""" """
Execute a workflow and poll for completion (useful for long-running workflows). Execute a workflow synchronously (ensures non-async mode).
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: Args:
workflow_id: The ID of the workflow to execute 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 timeout: Timeout for the initial request in seconds
stream: Enable streaming responses (default: None) stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"]) selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
@@ -344,9 +342,14 @@ class SimStudioClient:
Raises: Raises:
SimStudioError: If the workflow execution fails SimStudioError: If the workflow execution fails
""" """
# For now, the API is synchronous, so we just execute directly return self.execute_workflow(
# In the future, if async execution is added, this method can be enhanced workflow_id,
return self.execute_workflow(workflow_id, input_data, timeout, stream, selected_outputs) input,
timeout=timeout,
stream=stream,
selected_outputs=selected_outputs,
async_execution=False
)
def set_api_key(self, api_key: str) -> None: def set_api_key(self, api_key: str) -> None:
""" """
@@ -410,7 +413,8 @@ class SimStudioClient:
def execute_with_retry( def execute_with_retry(
self, self,
workflow_id: str, workflow_id: str,
input_data: Optional[Dict[str, Any]] = None, input: Optional[Any] = None,
*,
timeout: float = 30.0, timeout: float = 30.0,
stream: Optional[bool] = None, stream: Optional[bool] = None,
selected_outputs: Optional[list] = None, selected_outputs: Optional[list] = None,
@@ -425,7 +429,7 @@ class SimStudioClient:
Args: Args:
workflow_id: The ID of the workflow to execute 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 timeout: Timeout in seconds
stream: Enable streaming responses stream: Enable streaming responses
selected_outputs: Block outputs to stream selected_outputs: Block outputs to stream
@@ -448,11 +452,11 @@ class SimStudioClient:
try: try:
return self.execute_workflow( return self.execute_workflow(
workflow_id, workflow_id,
input_data, input,
timeout, timeout=timeout,
stream, stream=stream,
selected_outputs, selected_outputs=selected_outputs,
async_execution async_execution=async_execution
) )
except SimStudioError as e: except SimStudioError as e:
if e.code != 'RATE_LIMIT_EXCEEDED': if e.code != 'RATE_LIMIT_EXCEEDED':

View File

@@ -91,11 +91,9 @@ def test_context_manager(mock_close):
"""Test SimStudioClient as context manager.""" """Test SimStudioClient as context manager."""
with SimStudioClient(api_key="test-api-key") as client: with SimStudioClient(api_key="test-api-key") as client:
assert client.api_key == "test-api-key" 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') @patch('simstudio.requests.Session.post')
def test_async_execution_returns_task_id(mock_post): def test_async_execution_returns_task_id(mock_post):
"""Test async execution returns AsyncExecutionResult.""" """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") client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow( result = client.execute_workflow(
"workflow-id", "workflow-id",
input_data={"message": "Hello"}, {"message": "Hello"},
async_execution=True async_execution=True
) )
@@ -124,7 +122,6 @@ def test_async_execution_returns_task_id(mock_post):
assert result.status == "queued" assert result.status == "queued"
assert result.links["status"] == "/api/jobs/task-123" assert result.links["status"] == "/api/jobs/task-123"
# Verify X-Execution-Mode header was set
call_args = mock_post.call_args call_args = mock_post.call_args
assert call_args[1]["headers"]["X-Execution-Mode"] == "async" 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") client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow( result = client.execute_workflow(
"workflow-id", "workflow-id",
input_data={"message": "Hello"}, {"message": "Hello"},
async_execution=False async_execution=False
) )
@@ -166,13 +163,12 @@ def test_async_header_not_set_when_false(mock_post):
mock_post.return_value = mock_response mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key") 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 call_args = mock_post.call_args
assert "X-Execution-Mode" not in call_args[1]["headers"] assert "X-Execution-Mode" not in call_args[1]["headers"]
# Tests for job status
@patch('simstudio.requests.Session.get') @patch('simstudio.requests.Session.get')
def test_get_job_status_success(mock_get): def test_get_job_status_success(mock_get):
"""Test getting job status.""" """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) assert "Job not found" in str(exc_info.value)
# Tests for retry with rate limiting
@patch('simstudio.requests.Session.post') @patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep') @patch('simstudio.time.sleep')
def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post): 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 mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key") 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 result.success is True
assert mock_post.call_count == 1 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") client = SimStudioClient(api_key="test-api-key")
result = client.execute_with_retry( result = client.execute_with_retry(
"workflow-id", "workflow-id",
input_data={"message": "test"}, {"message": "test"},
max_retries=3, max_retries=3,
initial_delay=0.01 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: with pytest.raises(SimStudioError) as exc_info:
client.execute_with_retry( client.execute_with_retry(
"workflow-id", "workflow-id",
input_data={"message": "test"}, {"message": "test"},
max_retries=2, max_retries=2,
initial_delay=0.01 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") client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info: 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 "Server error" in str(exc_info.value)
assert mock_post.call_count == 1 # No retries assert mock_post.call_count == 1 # No retries
# Tests for rate limit info
def test_get_rate_limit_info_returns_none_initially(): def test_get_rate_limit_info_returns_none_initially():
"""Test rate limit info is None before any API calls.""" """Test rate limit info is None before any API calls."""
client = SimStudioClient(api_key="test-api-key") 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 mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key") 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() info = client.get_rate_limit_info()
assert info is not None assert info is not None
@@ -371,7 +365,6 @@ def test_get_rate_limit_info_after_api_call(mock_post):
assert info.reset == 1704067200 assert info.reset == 1704067200
# Tests for usage limits
@patch('simstudio.requests.Session.get') @patch('simstudio.requests.Session.get')
def test_get_usage_limits_success(mock_get): def test_get_usage_limits_success(mock_get):
"""Test getting usage limits.""" """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) assert "Invalid API key" in str(exc_info.value)
# Tests for streaming with selectedOutputs
@patch('simstudio.requests.Session.post') @patch('simstudio.requests.Session.post')
def test_execute_workflow_with_stream_and_selected_outputs(mock_post): def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
"""Test execution with stream and selectedOutputs parameters.""" """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 = SimStudioClient(api_key="test-api-key")
client.execute_workflow( client.execute_workflow(
"workflow-id", "workflow-id",
input_data={"message": "test"}, {"message": "test"},
stream=True, stream=True,
selected_outputs=["agent1.content", "agent2.content"] 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["message"] == "test"
assert request_body["stream"] is True 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

View File

@@ -71,6 +71,19 @@ vi.mock('@/executor/path')
vi.mock('@/executor/resolver', () => ({ vi.mock('@/executor/resolver', () => ({
InputResolver: vi.fn(), InputResolver: vi.fn(),
})) }))
vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')),
extractAPIErrorMessage: vi.fn(async (response: Response) => {
const defaultMessage = `API request failed with status ${response.status}`
try {
const errorData = await response.json()
return errorData.error || defaultMessage
} catch {
return defaultMessage
}
}),
}))
// Specific block utilities // Specific block utilities
vi.mock('@/blocks/blocks/router') vi.mock('@/blocks/blocks/router')

View File

@@ -47,24 +47,35 @@ new SimStudioClient(config: SimStudioConfig)
#### Methods #### Methods
##### executeWorkflow(workflowId, options?) ##### executeWorkflow(workflowId, input?, options?)
Execute a workflow with optional input data. Execute a workflow with optional input data.
```typescript ```typescript
// With object input (spread at root level of request body)
const result = await client.executeWorkflow('workflow-id', { const result = await client.executeWorkflow('workflow-id', {
input: { message: 'Hello, world!' }, message: 'Hello, world!'
timeout: 30000 // 30 seconds });
// 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:** **Parameters:**
- `workflowId` (string): The ID of the workflow to execute - `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): - `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) - `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) ##### getWorkflowStatus(workflowId)
@@ -96,25 +107,89 @@ if (isReady) {
**Returns:** `Promise<boolean>` **Returns:** `Promise<boolean>`
##### executeWorkflowSync(workflowId, options?) ##### executeWorkflowSync(workflowId, input?, options?)
Execute a workflow and poll for completion (useful for long-running workflows). Execute a workflow and poll for completion (useful for long-running workflows).
```typescript ```typescript
const result = await client.executeWorkflowSync('workflow-id', { const result = await client.executeWorkflowSync('workflow-id', { data: 'some input' }, {
input: { data: 'some input' },
timeout: 60000 timeout: 60000
}); });
``` ```
**Parameters:** **Parameters:**
- `workflowId` (string): The ID of the workflow to execute - `workflowId` (string): The ID of the workflow to execute
- `input` (any, optional): Input data to pass to the workflow
- `options` (ExecutionOptions, optional): - `options` (ExecutionOptions, optional):
- `input` (any): Input data to pass to the workflow
- `timeout` (number): Timeout for the initial request in milliseconds - `timeout` (number): Timeout for the initial request in milliseconds
**Returns:** `Promise<WorkflowExecutionResult>` **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) ##### setApiKey(apiKey)
Update the API key. 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 ## Examples
### Basic Workflow Execution ### Basic Workflow Execution
@@ -191,10 +341,8 @@ async function runWorkflow() {
// Execute the workflow // Execute the workflow
const result = await client.executeWorkflow('my-workflow-id', { const result = await client.executeWorkflow('my-workflow-id', {
input: { message: 'Process this data',
message: 'Process this data', userId: '12345'
userId: '12345'
}
}); });
if (result.success) { 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 // Include files under the field name from your API trigger's input format
const result = await client.executeWorkflow('workflow-id', { const result = await client.executeWorkflow('workflow-id', {
input: { documents: [file], // Field name must match your API trigger's file input field
documents: [file], // Field name must match your API trigger's file input field instructions: 'Process this document'
instructions: 'Process this document'
}
}); });
// Browser: From file input // Browser: From file input
const handleFileUpload = async (event: Event) => { const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement; const inputEl = event.target as HTMLInputElement;
const files = Array.from(input.files || []); const files = Array.from(inputEl.files || []);
const result = await client.executeWorkflow('workflow-id', { const result = await client.executeWorkflow('workflow-id', {
input: { attachments: files, // Field name must match your API trigger's file input field
attachments: files, // Field name must match your API trigger's file input field query: 'Analyze these files'
query: 'Analyze these files'
}
}); });
}; };
``` ```

View File

@@ -1,6 +1,6 @@
{ {
"name": "simstudio-ts-sdk", "name": "simstudio-ts-sdk",
"version": "0.1.1", "version": "0.1.2",
"description": "Sim SDK - Execute workflows programmatically", "description": "Sim SDK - Execute workflows programmatically",
"type": "module", "type": "module",
"exports": { "exports": {

View File

@@ -119,10 +119,11 @@ describe('SimStudioClient', () => {
} }
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.executeWorkflow('workflow-id', { const result = await client.executeWorkflow(
input: { message: 'Hello' }, 'workflow-id',
async: true, { message: 'Hello' },
}) { async: true }
)
expect(result).toHaveProperty('taskId', 'task-123') expect(result).toHaveProperty('taskId', 'task-123')
expect(result).toHaveProperty('status', 'queued') expect(result).toHaveProperty('status', 'queued')
@@ -152,10 +153,11 @@ describe('SimStudioClient', () => {
} }
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.executeWorkflow('workflow-id', { const result = await client.executeWorkflow(
input: { message: 'Hello' }, 'workflow-id',
async: false, { message: 'Hello' },
}) { async: false }
)
expect(result).toHaveProperty('success', true) expect(result).toHaveProperty('success', true)
expect(result).toHaveProperty('output') expect(result).toHaveProperty('output')
@@ -177,9 +179,7 @@ describe('SimStudioClient', () => {
} }
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await client.executeWorkflow('workflow-id', { await client.executeWorkflow('workflow-id', { message: 'Hello' })
input: { message: 'Hello' },
})
const calls = vi.mocked(fetch.default).mock.calls const calls = vi.mocked(fetch.default).mock.calls
expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode') expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode')
@@ -256,9 +256,7 @@ describe('SimStudioClient', () => {
} }
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
const result = await client.executeWithRetry('workflow-id', { const result = await client.executeWithRetry('workflow-id', { message: 'test' })
input: { message: 'test' },
})
expect(result).toHaveProperty('success', true) expect(result).toHaveProperty('success', true)
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1)
@@ -305,7 +303,8 @@ describe('SimStudioClient', () => {
const result = await client.executeWithRetry( const result = await client.executeWithRetry(
'workflow-id', 'workflow-id',
{ input: { message: 'test' } }, { message: 'test' },
{},
{ maxRetries: 3, initialDelay: 10 } { maxRetries: 3, initialDelay: 10 }
) )
@@ -336,7 +335,8 @@ describe('SimStudioClient', () => {
await expect( await expect(
client.executeWithRetry( client.executeWithRetry(
'workflow-id', 'workflow-id',
{ input: { message: 'test' } }, { message: 'test' },
{},
{ maxRetries: 2, initialDelay: 10 } { maxRetries: 2, initialDelay: 10 }
) )
).rejects.toThrow('Rate limit exceeded') ).rejects.toThrow('Rate limit exceeded')
@@ -361,9 +361,9 @@ describe('SimStudioClient', () => {
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await expect( await expect(client.executeWithRetry('workflow-id', { message: 'test' })).rejects.toThrow(
client.executeWithRetry('workflow-id', { input: { message: 'test' } }) 'Server error'
).rejects.toThrow('Server error') )
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) // No retries expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) // No retries
}) })
@@ -393,7 +393,7 @@ describe('SimStudioClient', () => {
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await client.executeWorkflow('workflow-id', { input: {} }) await client.executeWorkflow('workflow-id', {})
const info = client.getRateLimitInfo() const info = client.getRateLimitInfo()
expect(info).not.toBeNull() expect(info).not.toBeNull()
@@ -490,11 +490,11 @@ describe('SimStudioClient', () => {
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
await client.executeWorkflow('workflow-id', { await client.executeWorkflow(
input: { message: 'test' }, 'workflow-id',
stream: true, { message: 'test' },
selectedOutputs: ['agent1.content', 'agent2.content'], { stream: true, selectedOutputs: ['agent1.content', 'agent2.content'] }
}) )
const calls = vi.mocked(fetch.default).mock.calls const calls = vi.mocked(fetch.default).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string) const requestBody = JSON.parse(calls[0][1]?.body as string)
@@ -505,6 +505,134 @@ describe('SimStudioClient', () => {
expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content']) 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', () => { describe('SimStudioError', () => {

View File

@@ -26,7 +26,6 @@ export interface WorkflowStatus {
} }
export interface ExecutionOptions { export interface ExecutionOptions {
input?: any
timeout?: number timeout?: number
stream?: boolean stream?: boolean
selectedOutputs?: string[] selectedOutputs?: string[]
@@ -117,10 +116,6 @@ export class SimStudioClient {
this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai') 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) * Convert File objects in input to API format (base64)
* Recursively processes nested objects and arrays * Recursively processes nested objects and arrays
@@ -170,20 +165,25 @@ export class SimStudioClient {
return value 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( async executeWorkflow(
workflowId: string, workflowId: string,
input?: any,
options: ExecutionOptions = {} options: ExecutionOptions = {}
): Promise<WorkflowExecutionResult | AsyncExecutionResult> { ): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
const url = `${this.baseUrl}/api/workflows/${workflowId}/execute` const url = `${this.baseUrl}/api/workflows/${workflowId}/execute`
const { input, timeout = 30000, stream, selectedOutputs, async } = options const { timeout = 30000, stream, selectedOutputs, async } = options
try { try {
// Create a timeout promise
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('TIMEOUT')), timeout) setTimeout(() => reject(new Error('TIMEOUT')), timeout)
}) })
// Build headers - async execution uses X-Execution-Mode header
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': this.apiKey, 'X-API-Key': this.apiKey,
@@ -192,10 +192,15 @@ export class SimStudioClient {
headers['X-Execution-Mode'] = 'async' headers['X-Execution-Mode'] = 'async'
} }
// Build JSON body - spread input at root level, then add API control parameters let jsonBody: any = {}
let jsonBody: any = input !== undefined ? { ...input } : {} 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) jsonBody = await this.convertFilesToBase64(jsonBody)
if (stream !== undefined) { if (stream !== undefined) {
@@ -213,10 +218,8 @@ export class SimStudioClient {
const response = await Promise.race([fetchPromise, timeoutPromise]) const response = await Promise.race([fetchPromise, timeoutPromise])
// Extract rate limit headers
this.updateRateLimitInfo(response) this.updateRateLimitInfo(response)
// Handle rate limiting with retry
if (response.status === 429) { if (response.status === 429) {
const retryAfter = this.rateLimitInfo?.retryAfter || 1000 const retryAfter = this.rateLimitInfo?.retryAfter || 1000
throw new SimStudioError( 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( async executeWorkflowSync(
workflowId: string, workflowId: string,
input?: any,
options: ExecutionOptions = {} options: ExecutionOptions = {}
): Promise<WorkflowExecutionResult> { ): Promise<WorkflowExecutionResult> {
// Ensure sync mode by explicitly setting async to false
const syncOptions = { ...options, async: 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 * 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( async executeWithRetry(
workflowId: string, workflowId: string,
input?: any,
options: ExecutionOptions = {}, options: ExecutionOptions = {},
retryOptions: RetryOptions = {} retryOptions: RetryOptions = {}
): Promise<WorkflowExecutionResult | AsyncExecutionResult> { ): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
@@ -379,7 +390,7 @@ export class SimStudioClient {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
return await this.executeWorkflow(workflowId, options) return await this.executeWorkflow(workflowId, input, options)
} catch (error: any) { } catch (error: any) {
if (!(error instanceof SimStudioError) || error.code !== 'RATE_LIMIT_EXCEEDED') { if (!(error instanceof SimStudioError) || error.code !== 'RATE_LIMIT_EXCEEDED') {
throw error throw error
@@ -387,23 +398,19 @@ export class SimStudioClient {
lastError = error lastError = error
// Don't retry after last attempt
if (attempt === maxRetries) { if (attempt === maxRetries) {
break break
} }
// Use retry-after if provided, otherwise use exponential backoff
const waitTime = const waitTime =
error.status === 429 && this.rateLimitInfo?.retryAfter error.status === 429 && this.rateLimitInfo?.retryAfter
? this.rateLimitInfo.retryAfter ? this.rateLimitInfo.retryAfter
: Math.min(delay, maxDelay) : Math.min(delay, maxDelay)
// Add jitter (±25%)
const jitter = waitTime * (0.75 + Math.random() * 0.5) const jitter = waitTime * (0.75 + Math.random() * 0.5)
await new Promise((resolve) => setTimeout(resolve, jitter)) await new Promise((resolve) => setTimeout(resolve, jitter))
// Exponential backoff for next attempt
delay *= backoffMultiplier delay *= backoffMultiplier
} }
} }
@@ -475,5 +482,4 @@ export class SimStudioClient {
} }
} }
// Export types and classes
export { SimStudioClient as default } export { SimStudioClient as default }

View File

@@ -126,7 +126,7 @@ async function fetchGitHubCommitDetails(
const githubUsername = commit.author?.login || commit.committer?.login || 'unknown' 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) { if (prNumber) {
cleanMessage = cleanMessage.replace(/\s*\(#\d+\)\s*$/, '') cleanMessage = cleanMessage.replace(/\s*\(#\d+\)\s*$/, '')
} }
@@ -226,12 +226,23 @@ async function getCommitsBetweenVersions(
function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements' | 'other' { function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements' | 'other' {
const msgLower = message.toLowerCase() const msgLower = message.toLowerCase()
if ( if (/^feat(\(|:|!)/.test(msgLower)) {
msgLower.includes('feat') || return 'features'
msgLower.includes('add') || }
msgLower.includes('implement') ||
msgLower.includes('new ') 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' return 'features'
} }
@@ -242,9 +253,10 @@ function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements
if ( if (
msgLower.includes('improve') || msgLower.includes('improve') ||
msgLower.includes('enhance') || msgLower.includes('enhance') ||
msgLower.includes('update') ||
msgLower.includes('upgrade') || msgLower.includes('upgrade') ||
msgLower.includes('optimization') msgLower.includes('optimization') ||
msgLower.includes('add') ||
msgLower.includes('update')
) { ) {
return 'improvements' return 'improvements'
} }