Compare commits

..

41 Commits

Author SHA1 Message Date
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
Emir Karabeg
294b168ed9 feat(broadcast): email v0.5 (#2905) 2026-01-20 23:42:48 -08:00
Waleed
0dc2c1fe0d 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
2026-01-20 23:13:05 -08:00
Vikhyath Mondreti
fb90c4e9b1 fix(change-detection): copilot diffs have extra field (#2913) 2026-01-20 22:04:08 -08:00
Vikhyath Mondreti
0af96d06c6 fix(a2a): canonical merge (#2912)
* fix canonical merge

* fix empty array case
2026-01-20 21:58:13 -08:00
Vikhyath Mondreti
1d450578c8 fix(copilot): legacy tool display names (#2911) 2026-01-20 21:16:48 -08:00
Waleed
c6d408c65b fix(canvas): removed invite to workspace from canvas popover (#2908)
* fix(canvas): removed invite to workspace

* removed unused props
2026-01-20 20:29:53 -08:00
Waleed
16716ea26a fix(ui): change add inputs button to match output selector (#2907) 2026-01-20 19:24:59 -08:00
Waleed
563098ca0a 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>
2026-01-20 18:41:26 -08:00
Vikhyath Mondreti
1f1f015031 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
2026-01-20 17:49:00 -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
168 changed files with 5154 additions and 3717 deletions

View File

@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
</p>
### Build Workflows with Ease

View File

@@ -4093,6 +4093,23 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='10 14 60 52'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<path
d='M22.0624102,50 C24.3763895,53.603 28.4103535,56 33.0003125,56 C40.1672485,56 45.9991964,50.168 45.9991964,43 C45.9991964,35.832 40.1672485,30 33.0003125,30 C27.6033607,30 22.9664021,33.307 21.0024196,38 L23.2143999,38 C25.0393836,34.444 28.7363506,32 33.0003125,32 C39.0652583,32 43.9992143,36.935 43.9992143,43 C43.9992143,49.065 39.0652583,54 33.0003125,54 C29.5913429,54 26.5413702,52.441 24.5213882,50 L22.0624102,50 Z M37.0002768,45 L37.0002768,43 L41.9992321,43 C41.9992321,38.038 37.9622682,34 33.0003125,34 C28.0373568,34 23.9993929,38.038 23.9993929,43 L28.9993482,43 L28.9993482,45 L24.2313908,45 C25.1443826,49.002 28.7253507,52 33.0003125,52 C35.1362934,52 37.0992759,51.249 38.6442621,50 L34.0003036,50 L34.0003036,48 L40.4782457,48 C41.0812403,47.102 41.5202364,46.087 41.7682342,45 L37.0002768,45 Z M21.0024196,48 L23.2143999,48 C22.4434068,46.498 22.0004107,44.801 22.0004107,43 C22.0004107,41.959 22.1554093,40.955 22.4264069,40 L20.3634253,40 C20.1344274,40.965 19.9994286,41.966 19.9994286,43 C19.9994286,44.771 20.3584254,46.46 21.0024196,48 L21.0024196,48 Z M19.7434309,50 L17.0004554,50 L17.0004554,48 L18.8744386,48 C18.5344417,47.04 18.2894438,46.038 18.1494451,45 L15.4144695,45 L16.707458,46.293 L15.2924706,47.707 L12.2924974,44.707 C11.9025009,44.316 11.9025009,43.684 12.2924974,43.293 L15.2924706,40.293 L16.707458,41.707 L15.4144695,43 L18.0004464,43 C18.0004464,41.973 18.1044455,40.97 18.3024437,40 L17.0004554,40 L17.0004554,38 L18.8744386,38 C20.9404202,32.184 26.4833707,28 33.0003125,28 C37.427273,28 41.4002375,29.939 44.148213,33 L59.0000804,33 L59.0000804,35 L45.6661994,35 C47.1351863,37.318 47.9991786,40.058 47.9991786,43 L59.0000804,43 L59.0000804,45 L47.8501799,45 C46.8681887,52.327 40.5912447,58 33.0003125,58 C27.2563638,58 22.2624084,54.752 19.7434309,50 L19.7434309,50 Z M37.0002768,39 C37.0002768,38.448 36.5522808,38 36.0002857,38 L29.9993482,38 C29.4473442,38 28.9993482,38.448 28.9993482,39 L28.9993482,41 L31.0003304,41 L31.0003304,40 L32.0003214,40 L32.0003214,43 L31.0003304,43 L31.0003304,45 L35.0002946,45 L35.0002946,43 L34.0003036,43 L34.0003036,40 L35.0002946,40 L35.0002946,41 L37.0002768,41 L37.0002768,39 Z M49.0001696,40 L59.0000804,40 L59.0000804,38 L49.0001696,38 L49.0001696,40 Z M49.0001696,50 L59.0000804,50 L59.0000804,48 L49.0001696,48 L49.0001696,50 Z M57.0000982,27 L60.5850662,27 L57.0000982,23.414 L57.0000982,27 Z M63.7070383,27.293 C63.8940367,27.48 64.0000357,27.735 64.0000357,28 L64.0000357,63 C64.0000357,63.552 63.5520397,64 63.0000446,64 L32.0003304,64 C31.4473264,64 31.0003304,63.552 31.0003304,63 L31.0003304,59 L33.0003125,59 L33.0003125,62 L62.0000536,62 L62.0000536,29 L56.0001071,29 C55.4471121,29 55.0001161,28.552 55.0001161,28 L55.0001161,22 L33.0003125,22 L33.0003125,27 L31.0003304,27 L31.0003304,21 C31.0003304,20.448 31.4473264,20 32.0003304,20 L56.0001071,20 C56.2651048,20 56.5191025,20.105 56.7071008,20.293 L63.7070383,27.293 Z M68,24.166 L68,61 C68,61.552 67.552004,62 67.0000089,62 L65.0000268,62 L65.0000268,60 L66.0000179,60 L66.0000179,24.612 L58.6170838,18 L36.0002857,18 L36.0002857,19 L34.0003036,19 L34.0003036,17 C34.0003036,16.448 34.4472996,16 35.0003036,16 L59.0000804,16 C59.2460782,16 59.483076,16.091 59.6660744,16.255 L67.666003,23.42 C67.8780011,23.61 68,23.881 68,24.166 L68,24.166 Z'
fill='currentColor'
/>
</svg>
)
}
export function McpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -110,6 +110,7 @@ import {
SupabaseIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
TinybirdIcon,
TranslateIcon,
TrelloIcon,
@@ -143,7 +144,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
calendly: CalendlyIcon,
circleback: CirclebackIcon,
clay: ClayIcon,
confluence: ConfluenceIcon,
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
datadog: DatadogIcon,
discord: DiscordIcon,
@@ -153,7 +154,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
exa: ExaAIIcon,
file: DocumentIcon,
file_v2: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies: FirefliesIcon,
github_v2: GithubIcon,
@@ -195,7 +196,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
microsoft_excel_v2: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
mistral_parse: MistralIcon,
mistral_parse_v2: MistralIcon,
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
@@ -237,6 +238,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract: TextractIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,
@@ -244,7 +246,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
video_generator: VideoIcon,
video_generator_v2: VideoIcon,
vision: EyeIcon,
wealthbox: WealthboxIcon,
webflow: WebflowIcon,

View File

@@ -6,7 +6,7 @@ description: Interact with Confluence
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="confluence"
type="confluence_v2"
color="#E0E0E0"
/>

View File

@@ -6,7 +6,7 @@ description: Read and parse multiple files
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="file"
type="file_v2"
color="#40916C"
/>
@@ -48,7 +48,7 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | array | Array of parsed files |
| `combinedContent` | string | Combined content of all parsed files |
| `files` | array | Array of parsed files with content, metadata, and file properties |
| `combinedContent` | string | All file contents merged into a single text string |

View File

@@ -106,6 +106,7 @@
"supabase",
"tavily",
"telegram",
"textract",
"tinybird",
"translate",
"trello",

View File

@@ -6,7 +6,7 @@ description: Extract text from PDF documents
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mistral_parse"
type="mistral_parse_v2"
color="#000000"
/>
@@ -54,18 +54,37 @@ Parse PDF documents using Mistral OCR API
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the PDF was parsed successfully |
| `content` | string | Extracted content in the requested format \(markdown, text, or JSON\) |
| `metadata` | object | Processing metadata including jobId, fileType, pageCount, and usage info |
| ↳ `jobId` | string | Unique job identifier |
| ↳ `fileType` | string | File type \(e.g., pdf\) |
| ↳ `fileName` | string | Original file name |
| ↳ `source` | string | Source type \(url\) |
| ↳ `pageCount` | number | Number of pages processed |
| ↳ `model` | string | Mistral model used |
| ↳ `resultType` | string | Output format \(markdown, text, json\) |
| ↳ `processedAt` | string | Processing timestamp |
| ↳ `sourceUrl` | string | Source URL if applicable |
| ↳ `usageInfo` | object | Usage statistics from OCR processing |
| `pages` | array | Array of page objects from Mistral OCR |
| ↳ `index` | number | Page index \(zero-based\) |
| ↳ `markdown` | string | Extracted markdown content |
| ↳ `images` | array | Images extracted from this page with bounding boxes |
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
| ↳ `dimensions` | object | Page dimensions |
| ↳ `dpi` | number | Dots per inch |
| ↳ `height` | number | Page height in pixels |
| ↳ `width` | number | Page width in pixels |
| ↳ `dpi` | number | Dots per inch |
| ↳ `height` | number | Page height in pixels |
| ↳ `width` | number | Page width in pixels |
| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] |
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \[ |
| ↳ `header` | string | Page header content \(when extract_header=true\) |
| ↳ `footer` | string | Page footer content \(when extract_footer=true\) |
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
| `usage_info` | object | Usage and processing statistics |
| ↳ `pages_processed` | number | Total number of pages processed |
| ↳ `doc_size_bytes` | number | Document file size in bytes |
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |

View File

@@ -58,6 +58,7 @@ Upload a file to an AWS S3 bucket
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | URL of the uploaded S3 object |
| `uri` | string | S3 URI of the uploaded object \(s3://bucket/key\) |
| `metadata` | object | Upload metadata including ETag and location |
### `s3_get_object`
@@ -149,6 +150,7 @@ Copy an object within or between AWS S3 buckets
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | URL of the copied S3 object |
| `uri` | string | S3 URI of the copied object \(s3://bucket/key\) |
| `metadata` | object | Copy operation metadata |

View File

@@ -0,0 +1,120 @@
---
title: AWS Textract
description: Extract text, tables, and forms from documents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="textract"
color="linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AWS Textract](https://aws.amazon.com/textract/) is a powerful AI service from Amazon Web Services designed to automatically extract printed text, handwriting, tables, forms, key-value pairs, and other structured data from scanned documents and images. Textract leverages advanced optical character recognition (OCR) and document analysis to transform documents into actionable data, enabling automation, analytics, compliance, and more.
With AWS Textract, you can:
- **Extract text from images and documents**: Recognize printed text and handwriting in formats such as PDF, JPEG, PNG, or TIFF
- **Detect and extract tables**: Automatically find tables and output their structured content
- **Parse forms and key-value pairs**: Pull structured data from forms, including fields and their corresponding values
- **Identify signatures and layout features**: Detect signatures, geometric layout, and relationships between document elements
- **Customize extraction with queries**: Extract specific fields and answers using query-based extraction (e.g., "What is the invoice number?")
In Sim, the AWS Textract integration empowers your agents to intelligently process documents as part of their workflows. This unlocks automation scenarios such as data entry from invoices, onboarding documents, contracts, receipts, and more. Your agents can extract relevant data, analyze structured forms, and generate summaries or reports directly from document uploads or URLs. By connecting Sim with AWS Textract, you can reduce manual effort, improve data accuracy, and streamline your business processes with robust document understanding.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.
## Tools
### `textract_parser`
Parse documents using AWS Textract OCR and document analysis
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | AWS Access Key ID |
| `secretAccessKey` | string | Yes | AWS Secret Access Key |
| `region` | string | Yes | AWS region for Textract service \(e.g., us-east-1\) |
| `processingMode` | string | No | Document type: single-page or multi-page. Defaults to single-page. |
| `filePath` | string | No | URL to a document to be processed \(JPEG, PNG, or single-page PDF\). |
| `s3Uri` | string | No | S3 URI for multi-page processing \(s3://bucket/key\). |
| `fileUpload` | object | No | File upload data from file-upload component |
| `featureTypes` | array | No | Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed. |
| `items` | string | No | Feature type |
| `queries` | array | No | Custom queries to extract specific information. Only used when featureTypes includes QUERIES. |
| `items` | object | No | Query configuration |
| `properties` | string | No | The query text |
| `Text` | string | No | No description |
| `Alias` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements |
| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) |
| ↳ `Id` | string | Unique identifier for the block |
| ↳ `Text` | string | Query text |
| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) |
| ↳ `Confidence` | number | Confidence score \(0-100\) |
| ↳ `Page` | number | Page number |
| ↳ `Geometry` | object | Location and bounding box information |
| ↳ `BoundingBox` | object | Height as ratio of document height |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Polygon` | array | Polygon coordinates |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `BoundingBox` | object | Height as ratio of document height |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Polygon` | array | Polygon coordinates |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `Relationships` | array | Relationships to other blocks |
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
| ↳ `Ids` | array | IDs of related blocks |
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
| ↳ `Ids` | array | IDs of related blocks |
| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) |
| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED |
| ↳ `RowIndex` | number | Row index for table cells |
| ↳ `ColumnIndex` | number | Column index for table cells |
| ↳ `RowSpan` | number | Row span for merged cells |
| ↳ `ColumnSpan` | number | Column span for merged cells |
| ↳ `Query` | object | Query information for QUERY blocks |
| ↳ `Text` | string | Query text |
| ↳ `Alias` | string | Query alias |
| ↳ `Pages` | array | Pages to search |
| ↳ `Alias` | string | Query alias |
| ↳ `Pages` | array | Pages to search |
| `documentMetadata` | object | Metadata about the analyzed document |
| ↳ `pages` | number | Number of pages in the document |
| `modelVersion` | string | Version of the Textract model used for processing |

View File

@@ -6,7 +6,7 @@ description: Generate videos from text using AI
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="video_generator"
type="video_generator_v2"
color="#181C1E"
/>

View File

@@ -224,7 +224,7 @@ export async function POST(req: NextRequest) {
hasApiKey: !!executionParams.apiKey,
})
const result = await executeTool(resolvedToolName, executionParams, true)
const result = await executeTool(resolvedToolName, executionParams)
logger.info(`[${tracker.requestId}] Tool execution complete`, {
toolName,

View File

@@ -1,364 +0,0 @@
import { db } from '@sim/db'
import { copilotChats, workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { getSession } from '@/lib/auth'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { COPILOT_MODEL_IDS } from '@/lib/copilot/models'
import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import {
createStream,
completeStream,
errorStream,
updateStreamStatus,
} from '@/lib/copilot/stream-persistence'
import { executeToolServerSide, isServerExecutableTool } from '@/lib/copilot/tools/server/executor'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { env } from '@/lib/core/config/env'
import { tools } from '@/tools/registry'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
const logger = createLogger('HeadlessCopilotAPI')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const HeadlessRequestSchema = z.object({
message: z.string().min(1, 'Message is required'),
workflowId: z.string().min(1, 'Workflow ID is required'),
chatId: z.string().optional(),
model: z.enum(COPILOT_MODEL_IDS).optional(),
mode: z.enum(['agent', 'build', 'chat']).optional().default('agent'),
timeout: z.number().optional().default(300000), // 5 minute default
persistChanges: z.boolean().optional().default(true),
createNewChat: z.boolean().optional().default(false),
})
export const dynamic = 'force-dynamic'
export const fetchCache = 'force-no-store'
export const runtime = 'nodejs'
/**
* POST /api/copilot/headless
*
* Execute copilot completely server-side without any client connection.
* All tool calls are executed server-side and results are persisted directly.
*
* Returns the final result after all processing is complete.
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
const startTime = Date.now()
try {
// Authenticate via session or API key
let userId: string | null = null
const session = await getSession()
if (session?.user?.id) {
userId = session.user.id
} else {
// Try API key authentication from header
const apiKey = req.headers.get('x-api-key')
if (apiKey) {
const authResult = await authenticateApiKeyFromHeader(apiKey)
if (authResult.success && authResult.userId) {
userId = authResult.userId
// Update last used timestamp in background
if (authResult.keyId) {
updateApiKeyLastUsed(authResult.keyId).catch(() => {})
}
}
}
}
if (!userId) {
return createUnauthorizedResponse()
}
const body = await req.json()
const { message, workflowId, chatId, model, mode, timeout, persistChanges, createNewChat } =
HeadlessRequestSchema.parse(body)
logger.info(`[${tracker.requestId}] Headless copilot request`, {
userId,
workflowId,
messageLength: message.length,
mode,
})
// Verify user has access to workflow
const [wf] = await db
.select({ userId: workflowTable.userId, workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!wf) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// TODO: Add proper workspace access check
if (wf.userId !== userId) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Load current workflow state from database
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
if (!workflowData) {
return NextResponse.json({ error: 'Workflow data not found' }, { status: 404 })
}
const sanitizedWorkflow = sanitizeForCopilot({
blocks: workflowData.blocks,
edges: workflowData.edges,
loops: workflowData.loops,
parallels: workflowData.parallels,
})
// Create a stream for tracking (even in headless mode)
const streamId = crypto.randomUUID()
const userMessageId = crypto.randomUUID()
const assistantMessageId = crypto.randomUUID()
await createStream({
streamId,
chatId: chatId || '',
userId,
workflowId,
userMessageId,
isClientSession: false, // Key: this is headless
})
await updateStreamStatus(streamId, 'streaming')
// Handle chat persistence
let actualChatId = chatId
if (createNewChat && !chatId) {
const { provider, model: defaultModel } = getCopilotModel('chat')
const [newChat] = await db
.insert(copilotChats)
.values({
userId,
workflowId,
title: null,
model: model || defaultModel,
messages: [],
})
.returning()
if (newChat) {
actualChatId = newChat.id
}
}
// Get credentials for tools
let credentials: {
oauth: Record<string, { accessToken: string; accountId: string; name: string }>
apiKeys: string[]
} | null = null
try {
const rawCredentials = await getCredentialsServerTool.execute({ workflowId }, { userId })
const oauthMap: Record<string, { accessToken: string; accountId: string; name: string }> = {}
for (const cred of rawCredentials?.oauth?.connected?.credentials || []) {
if (cred.accessToken) {
oauthMap[cred.provider] = {
accessToken: cred.accessToken,
accountId: cred.id,
name: cred.name,
}
}
}
credentials = {
oauth: oauthMap,
apiKeys: rawCredentials?.environment?.variableNames || [],
}
} catch (error) {
logger.warn(`[${tracker.requestId}] Failed to fetch credentials`, { error })
}
// Build tool definitions
const { createUserToolSchema } = await import('@/tools/params')
const latestTools = getLatestVersionTools(tools)
const integrationTools = Object.entries(latestTools).map(([toolId, toolConfig]) => {
const userSchema = createUserToolSchema(toolConfig)
const strippedName = stripVersionSuffix(toolId)
return {
name: strippedName,
description: toolConfig.description || toolConfig.name || strippedName,
input_schema: userSchema,
defer_loading: true,
}
})
// Build request payload
const defaults = getCopilotModel('chat')
const selectedModel = model || defaults.model
const effectiveMode = mode === 'agent' ? 'build' : mode
const requestPayload = {
message,
workflowId,
userId,
stream: false, // Non-streaming for headless
model: selectedModel,
mode: effectiveMode,
version: SIM_AGENT_VERSION,
messageId: userMessageId,
...(actualChatId && { chatId: actualChatId }),
...(integrationTools.length > 0 && { tools: integrationTools }),
...(credentials && { credentials }),
}
// Call sim agent (non-streaming)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify(requestPayload),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
logger.error(`[${tracker.requestId}] Sim agent error`, {
status: response.status,
error: errorText,
})
await errorStream(streamId, `Agent error: ${response.statusText}`)
return NextResponse.json(
{ error: `Agent error: ${response.statusText}` },
{ status: response.status }
)
}
const result = await response.json()
// Execute tool calls server-side
const toolResults: Record<string, { success: boolean; result?: unknown; error?: string }> = {}
if (result.toolCalls && Array.isArray(result.toolCalls)) {
for (const toolCall of result.toolCalls) {
const toolName = toolCall.name
const toolArgs = toolCall.arguments || toolCall.input || {}
logger.info(`[${tracker.requestId}] Executing tool server-side`, {
toolName,
toolCallId: toolCall.id,
})
if (!isServerExecutableTool(toolName)) {
logger.warn(`[${tracker.requestId}] Tool not executable server-side`, { toolName })
toolResults[toolCall.id] = {
success: false,
error: `Tool ${toolName} requires client-side execution`,
}
continue
}
const toolResult = await executeToolServerSide(
{ name: toolName, args: toolArgs },
{ workflowId, userId, persistChanges }
)
toolResults[toolCall.id] = toolResult
}
}
// Mark stream complete
await completeStream(streamId, { content: result.content, toolResults })
// Save to chat history
if (actualChatId && persistChanges) {
const [chat] = await db
.select()
.from(copilotChats)
.where(eq(copilotChats.id, actualChatId))
.limit(1)
const existingMessages = chat ? (Array.isArray(chat.messages) ? chat.messages : []) : []
const newMessages = [
...existingMessages,
{
id: userMessageId,
role: 'user',
content: message,
timestamp: new Date().toISOString(),
},
{
id: assistantMessageId,
role: 'assistant',
content: result.content,
timestamp: new Date().toISOString(),
toolCalls: Object.entries(toolResults).map(([id, r]) => ({
id,
success: r.success,
})),
},
]
await db
.update(copilotChats)
.set({ messages: newMessages, updatedAt: new Date() })
.where(eq(copilotChats.id, actualChatId))
}
const duration = Date.now() - startTime
logger.info(`[${tracker.requestId}] Headless copilot complete`, {
duration,
contentLength: result.content?.length || 0,
toolCallsExecuted: Object.keys(toolResults).length,
})
return NextResponse.json({
success: true,
streamId,
chatId: actualChatId,
content: result.content,
toolResults,
duration,
})
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
await errorStream(streamId, 'Request timed out')
return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
}
throw error
}
} catch (error) {
logger.error(`[${tracker.requestId}] Headless copilot error`, { error })
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 }
)
}
}

View File

@@ -1,237 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import {
getStreamMetadata,
getStreamEvents,
getStreamEventCount,
getToolCallStates,
refreshStreamTTL,
checkAbortSignal,
abortStream,
} from '@/lib/copilot/stream-persistence'
const logger = createLogger('StreamResumeAPI')
interface RouteParams {
streamId: string
}
/**
* GET /api/copilot/stream/{streamId}
* Subscribe to or resume a stream
*
* Query params:
* - offset: Start from this event index (for resumption)
* - mode: 'sse' (default) or 'poll'
*/
export async function GET(req: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { streamId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const metadata = await getStreamMetadata(streamId)
if (!metadata) {
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
}
// Verify user owns this stream
if (metadata.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const offset = parseInt(req.nextUrl.searchParams.get('offset') || '0', 10)
const mode = req.nextUrl.searchParams.get('mode') || 'sse'
// Refresh TTL since someone is actively consuming
await refreshStreamTTL(streamId)
// Poll mode: return current state as JSON
if (mode === 'poll') {
const events = await getStreamEvents(streamId, offset)
const toolCalls = await getToolCallStates(streamId)
const eventCount = await getStreamEventCount(streamId)
return NextResponse.json({
metadata,
events,
toolCalls,
totalEvents: eventCount,
nextOffset: offset + events.length,
})
}
// SSE mode: stream events
const encoder = new TextEncoder()
const readable = new ReadableStream({
async start(controller) {
let closed = false
const safeEnqueue = (data: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(data))
} catch {
closed = true
}
}
const safeClose = () => {
if (closed) return
closed = true
try {
controller.close()
} catch {
// Already closed
}
}
// Send initial connection event
safeEnqueue(`: connected\n\n`)
// Send metadata
safeEnqueue(`event: metadata\ndata: ${JSON.stringify(metadata)}\n\n`)
// Send tool call states
const toolCalls = await getToolCallStates(streamId)
if (Object.keys(toolCalls).length > 0) {
safeEnqueue(`event: tool_states\ndata: ${JSON.stringify(toolCalls)}\n\n`)
}
// Replay missed events
const missedEvents = await getStreamEvents(streamId, offset)
for (const event of missedEvents) {
safeEnqueue(event)
}
// If stream is complete, send done and close
if (metadata.status === 'complete' || metadata.status === 'error' || metadata.status === 'aborted') {
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({
status: metadata.status,
error: metadata.error,
})}\n\n`
)
safeClose()
return
}
// Stream is still active - poll for new events
let lastOffset = offset + missedEvents.length
const pollInterval = 100 // 100ms
const maxPollTime = 5 * 60 * 1000 // 5 minutes max
const startTime = Date.now()
const poll = async () => {
if (closed) return
try {
// Check for timeout
if (Date.now() - startTime > maxPollTime) {
logger.info('Stream poll timeout', { streamId })
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({ status: 'timeout' })}\n\n`
)
safeClose()
return
}
// Check if client disconnected
if (await checkAbortSignal(streamId)) {
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({ status: 'aborted' })}\n\n`
)
safeClose()
return
}
// Get current metadata to check status
const currentMeta = await getStreamMetadata(streamId)
if (!currentMeta) {
safeClose()
return
}
// Get new events
const newEvents = await getStreamEvents(streamId, lastOffset)
for (const event of newEvents) {
safeEnqueue(event)
}
lastOffset += newEvents.length
// Refresh TTL
await refreshStreamTTL(streamId)
// If complete, send status and close
if (
currentMeta.status === 'complete' ||
currentMeta.status === 'error' ||
currentMeta.status === 'aborted'
) {
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({
status: currentMeta.status,
error: currentMeta.error,
})}\n\n`
)
safeClose()
return
}
// Continue polling
setTimeout(poll, pollInterval)
} catch (error) {
logger.error('Stream poll error', { streamId, error })
safeClose()
}
}
// Start polling
setTimeout(poll, pollInterval)
},
})
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
'X-Stream-Id': streamId,
},
})
}
/**
* DELETE /api/copilot/stream/{streamId}
* Abort a stream
*/
export async function DELETE(req: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { streamId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const metadata = await getStreamMetadata(streamId)
if (!metadata) {
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
}
// Verify user owns this stream
if (metadata.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await abortStream(streamId)
logger.info('Stream aborted by user', { streamId, userId: session.user.id })
return NextResponse.json({ success: true, streamId })
}

View File

@@ -6,9 +6,10 @@ import { createLogger } from '@sim/logger'
import binaryExtensionsList from 'binary-extensions'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
import {
@@ -21,6 +22,7 @@ import {
} from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import type { UserFile } from '@/executor/types'
import '@/lib/uploads/core/setup.server'
export const dynamic = 'force-dynamic'
@@ -30,6 +32,12 @@ const logger = createLogger('FilesParseAPI')
const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB
const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds
interface ExecutionContext {
workspaceId: string
workflowId: string
executionId: string
}
interface ParseResult {
success: boolean
content?: string
@@ -37,6 +45,7 @@ interface ParseResult {
filePath: string
originalName?: string // Original filename from database (for workspace files)
viewerUrl?: string | null // Viewer URL for the file if available
userFile?: UserFile // UserFile object for the raw file
metadata?: {
fileType: string
size: number
@@ -70,27 +79,45 @@ export async function POST(request: NextRequest) {
const userId = authResult.userId
const requestData = await request.json()
const { filePath, fileType, workspaceId } = requestData
const { filePath, fileType, workspaceId, workflowId, executionId } = requestData
if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) {
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
}
logger.info('File parse request received:', { filePath, fileType, workspaceId, userId })
// Build execution context if all required fields are present
const executionContext: ExecutionContext | undefined =
workspaceId && workflowId && executionId
? { workspaceId, workflowId, executionId }
: undefined
logger.info('File parse request received:', {
filePath,
fileType,
workspaceId,
userId,
hasExecutionContext: !!executionContext,
})
if (Array.isArray(filePath)) {
const results = []
for (const path of filePath) {
if (!path || (typeof path === 'string' && path.trim() === '')) {
for (const singlePath of filePath) {
if (!singlePath || (typeof singlePath === 'string' && singlePath.trim() === '')) {
results.push({
success: false,
error: 'Empty file path in array',
filePath: path || '',
filePath: singlePath || '',
})
continue
}
const result = await parseFileSingle(path, fileType, workspaceId, userId)
const result = await parseFileSingle(
singlePath,
fileType,
workspaceId,
userId,
executionContext
)
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
}
@@ -106,6 +133,7 @@ export async function POST(request: NextRequest) {
fileType: result.metadata?.fileType || 'application/octet-stream',
size: result.metadata?.size || 0,
binary: false,
file: result.userFile,
},
filePath: result.filePath,
viewerUrl: result.viewerUrl,
@@ -121,7 +149,7 @@ export async function POST(request: NextRequest) {
})
}
const result = await parseFileSingle(filePath, fileType, workspaceId, userId)
const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext)
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
@@ -137,6 +165,7 @@ export async function POST(request: NextRequest) {
fileType: result.metadata?.fileType || 'application/octet-stream',
size: result.metadata?.size || 0,
binary: false,
file: result.userFile,
},
filePath: result.filePath,
viewerUrl: result.viewerUrl,
@@ -164,7 +193,8 @@ async function parseFileSingle(
filePath: string,
fileType: string,
workspaceId: string,
userId: string
userId: string,
executionContext?: ExecutionContext
): Promise<ParseResult> {
logger.info('Parsing file:', filePath)
@@ -186,18 +216,18 @@ async function parseFileSingle(
}
if (filePath.includes('/api/files/serve/')) {
return handleCloudFile(filePath, fileType, undefined, userId)
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
}
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return handleExternalUrl(filePath, fileType, workspaceId, userId)
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext)
}
if (isUsingCloudStorage()) {
return handleCloudFile(filePath, fileType, undefined, userId)
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
}
return handleLocalFile(filePath, fileType, userId)
return handleLocalFile(filePath, fileType, userId, executionContext)
}
/**
@@ -230,12 +260,14 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string
/**
* Handle external URL
* If workspaceId is provided, checks if file already exists and saves to workspace if not
* If executionContext is provided, also stores the file in execution storage and returns UserFile
*/
async function handleExternalUrl(
url: string,
fileType: string,
workspaceId: string,
userId: string
userId: string,
executionContext?: ExecutionContext
): Promise<ParseResult> {
try {
logger.info('Fetching external URL:', url)
@@ -312,17 +344,13 @@ async function handleExternalUrl(
if (existingFile) {
const storageFilePath = `/api/files/serve/${existingFile.key}`
return handleCloudFile(storageFilePath, fileType, 'workspace', userId)
return handleCloudFile(storageFilePath, fileType, 'workspace', userId, executionContext)
}
}
}
const pinnedUrl = createPinnedUrl(url, urlValidation.resolvedIP!)
const response = await fetch(pinnedUrl, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: {
Host: urlValidation.originalHostname!,
},
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
timeout: DOWNLOAD_TIMEOUT_MS,
})
if (!response.ok) {
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
@@ -341,6 +369,19 @@ async function handleExternalUrl(
logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`)
let userFile: UserFile | undefined
const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension)
if (executionContext) {
try {
userFile = await uploadExecutionFile(executionContext, buffer, filename, mimeType, userId)
logger.info(`Stored file in execution storage: ${filename}`, { key: userFile.key })
} catch (uploadError) {
logger.warn(`Failed to store file in execution storage:`, uploadError)
// Continue without userFile - parsing can still work
}
}
if (shouldCheckWorkspace) {
try {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
@@ -353,8 +394,6 @@ async function handleExternalUrl(
})
} else {
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
const mimeType =
response.headers.get('content-type') || getMimeTypeFromExtension(extension)
await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType)
logger.info(`Saved URL file to workspace storage: ${filename}`)
}
@@ -363,17 +402,23 @@ async function handleExternalUrl(
}
}
let parseResult: ParseResult
if (extension === 'pdf') {
return await handlePdfBuffer(buffer, filename, fileType, url)
}
if (extension === 'csv') {
return await handleCsvBuffer(buffer, filename, fileType, url)
}
if (isSupportedFileType(extension)) {
return await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
parseResult = await handlePdfBuffer(buffer, filename, fileType, url)
} else if (extension === 'csv') {
parseResult = await handleCsvBuffer(buffer, filename, fileType, url)
} else if (isSupportedFileType(extension)) {
parseResult = await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
} else {
parseResult = handleGenericBuffer(buffer, filename, extension, fileType)
}
return handleGenericBuffer(buffer, filename, extension, fileType)
// Attach userFile to the result
if (userFile) {
parseResult.userFile = userFile
}
return parseResult
} catch (error) {
logger.error(`Error handling external URL ${url}:`, error)
return {
@@ -386,12 +431,15 @@ async function handleExternalUrl(
/**
* Handle file stored in cloud storage
* If executionContext is provided and file is not already from execution storage,
* copies the file to execution storage and returns UserFile
*/
async function handleCloudFile(
filePath: string,
fileType: string,
explicitContext: string | undefined,
userId: string
userId: string,
executionContext?: ExecutionContext
): Promise<ParseResult> {
try {
const cloudKey = extractStorageKey(filePath)
@@ -438,6 +486,7 @@ async function handleCloudFile(
const filename = originalFilename || cloudKey.split('/').pop() || cloudKey
const extension = path.extname(filename).toLowerCase().substring(1)
const mimeType = getMimeTypeFromExtension(extension)
const normalizedFilePath = `/api/files/serve/${encodeURIComponent(cloudKey)}?context=${context}`
let workspaceIdFromKey: string | undefined
@@ -453,6 +502,39 @@ async function handleCloudFile(
const viewerUrl = getViewerUrl(cloudKey, workspaceIdFromKey)
// Store file in execution storage if executionContext is provided
let userFile: UserFile | undefined
if (executionContext) {
// If file is already from execution context, create UserFile reference without re-uploading
if (context === 'execution') {
userFile = {
id: `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
name: filename,
url: normalizedFilePath,
size: fileBuffer.length,
type: mimeType,
key: cloudKey,
context: 'execution',
}
logger.info(`Created UserFile reference for existing execution file: ${filename}`)
} else {
// Copy from workspace/other storage to execution storage
try {
userFile = await uploadExecutionFile(
executionContext,
fileBuffer,
filename,
mimeType,
userId
)
logger.info(`Copied file to execution storage: ${filename}`, { key: userFile.key })
} catch (uploadError) {
logger.warn(`Failed to copy file to execution storage:`, uploadError)
}
}
}
let parseResult: ParseResult
if (extension === 'pdf') {
parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, normalizedFilePath)
@@ -477,6 +559,11 @@ async function handleCloudFile(
parseResult.viewerUrl = viewerUrl
// Attach userFile to the result
if (userFile) {
parseResult.userFile = userFile
}
return parseResult
} catch (error) {
logger.error(`Error handling cloud file ${filePath}:`, error)
@@ -500,7 +587,8 @@ async function handleCloudFile(
async function handleLocalFile(
filePath: string,
fileType: string,
userId: string
userId: string,
executionContext?: ExecutionContext
): Promise<ParseResult> {
try {
const filename = filePath.split('/').pop() || filePath
@@ -540,13 +628,32 @@ async function handleLocalFile(
const hash = createHash('md5').update(fileBuffer).digest('hex')
const extension = path.extname(filename).toLowerCase().substring(1)
const mimeType = fileType || getMimeTypeFromExtension(extension)
// Store file in execution storage if executionContext is provided
let userFile: UserFile | undefined
if (executionContext) {
try {
userFile = await uploadExecutionFile(
executionContext,
fileBuffer,
filename,
mimeType,
userId
)
logger.info(`Stored local file in execution storage: ${filename}`, { key: userFile.key })
} catch (uploadError) {
logger.warn(`Failed to store local file in execution storage:`, uploadError)
}
}
return {
success: true,
content: result.content,
filePath,
userFile,
metadata: {
fileType: fileType || getMimeTypeFromExtension(extension),
fileType: mimeType,
size: stats.size,
hash,
processingTime: 0,

View File

@@ -1,395 +0,0 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { isDev } from '@/lib/core/config/feature-flags'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { executeTool } from '@/tools'
import { getTool, validateRequiredParametersAfterMerge } from '@/tools/utils'
const logger = createLogger('ProxyAPI')
const proxyPostSchema = z.object({
toolId: z.string().min(1, 'toolId is required'),
params: z.record(z.any()).optional().default({}),
executionContext: z
.object({
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
executionId: z.string().optional(),
userId: z.string().optional(),
})
.optional(),
})
/**
* Creates a minimal set of default headers for proxy requests
* @returns Record of HTTP headers
*/
const getProxyHeaders = (): Record<string, string> => {
return {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
Accept: '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
}
}
/**
* Formats a response with CORS headers
* @param responseData Response data object
* @param status HTTP status code
* @returns NextResponse with CORS headers
*/
const formatResponse = (responseData: any, status = 200) => {
return NextResponse.json(responseData, {
status,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}
/**
* Creates an error response with consistent formatting
* @param error Error object or message
* @param status HTTP status code
* @param additionalData Additional data to include in the response
* @returns Formatted error response
*/
const createErrorResponse = (error: any, status = 500, additionalData = {}) => {
const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : undefined
logger.error('Creating error response', {
errorMessage,
status,
stack: isDev ? errorStack : undefined,
})
return formatResponse(
{
success: false,
error: errorMessage,
stack: isDev ? errorStack : undefined,
...additionalData,
},
status
)
}
/**
* GET handler for direct external URL proxying
* This allows for GET requests to external APIs
*/
export async function GET(request: Request) {
const url = new URL(request.url)
const targetUrl = url.searchParams.get('url')
const requestId = generateRequestId()
// Vault download proxy: /api/proxy?vaultDownload=1&bucket=...&object=...&credentialId=...
const vaultDownload = url.searchParams.get('vaultDownload')
if (vaultDownload === '1') {
try {
const bucket = url.searchParams.get('bucket')
const objectParam = url.searchParams.get('object')
const credentialId = url.searchParams.get('credentialId')
if (!bucket || !objectParam || !credentialId) {
return createErrorResponse('Missing bucket, object, or credentialId', 400)
}
// Fetch access token using existing token API
const baseUrl = new URL(getBaseUrl())
const tokenUrl = new URL('/api/auth/oauth/token', baseUrl)
// Build headers: forward session cookies if present; include internal auth for server-side
const tokenHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
const incomingCookie = request.headers.get('cookie')
if (incomingCookie) tokenHeaders.Cookie = incomingCookie
try {
const internalToken = await generateInternalToken()
tokenHeaders.Authorization = `Bearer ${internalToken}`
} catch (_e) {
// best-effort internal auth
}
// Optional workflow context for collaboration auth
const workflowId = url.searchParams.get('workflowId') || undefined
const tokenRes = await fetch(tokenUrl.toString(), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({ credentialId, workflowId }),
})
if (!tokenRes.ok) {
const err = await tokenRes.text()
return createErrorResponse(`Failed to fetch access token: ${err}`, 401)
}
const tokenJson = await tokenRes.json()
const accessToken = tokenJson.accessToken
if (!accessToken) {
return createErrorResponse('No access token available', 401)
}
// Avoid double-encoding: incoming object may already be percent-encoded
const objectDecoded = decodeURIComponent(objectParam)
const gcsUrl = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(
bucket
)}/o/${encodeURIComponent(objectDecoded)}?alt=media`
const fileRes = await fetch(gcsUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!fileRes.ok) {
const errText = await fileRes.text()
return createErrorResponse(errText || 'Failed to download file', fileRes.status)
}
const headers = new Headers()
fileRes.headers.forEach((v, k) => headers.set(k, v))
return new NextResponse(fileRes.body, { status: 200, headers })
} catch (error: any) {
logger.error(`[${requestId}] Vault download proxy failed`, {
error: error instanceof Error ? error.message : String(error),
})
return createErrorResponse('Vault download failed', 500)
}
}
if (!targetUrl) {
logger.error(`[${requestId}] Missing 'url' parameter`)
return createErrorResponse("Missing 'url' parameter", 400)
}
const urlValidation = await validateUrlWithDNS(targetUrl)
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Blocked proxy request`, {
url: targetUrl.substring(0, 100),
error: urlValidation.error,
})
return createErrorResponse(urlValidation.error || 'Invalid URL', 403)
}
const method = url.searchParams.get('method') || 'GET'
const bodyParam = url.searchParams.get('body')
let body: string | undefined
if (bodyParam && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
try {
body = decodeURIComponent(bodyParam)
} catch (error) {
logger.warn(`[${requestId}] Failed to decode body parameter`, error)
}
}
const customHeaders: Record<string, string> = {}
for (const [key, value] of url.searchParams.entries()) {
if (key.startsWith('header.')) {
const headerName = key.substring(7)
customHeaders[headerName] = value
}
}
if (body && !customHeaders['Content-Type']) {
customHeaders['Content-Type'] = 'application/json'
}
logger.info(`[${requestId}] Proxying ${method} request to: ${targetUrl}`)
try {
const pinnedUrl = createPinnedUrl(targetUrl, urlValidation.resolvedIP!)
const response = await fetch(pinnedUrl, {
method: method,
headers: {
...getProxyHeaders(),
...customHeaders,
Host: urlValidation.originalHostname!,
},
body: body || undefined,
})
const contentType = response.headers.get('content-type') || ''
let data
if (contentType.includes('application/json')) {
data = await response.json()
} else {
data = await response.text()
}
const errorMessage = !response.ok
? data && typeof data === 'object' && data.error
? `${data.error.message || JSON.stringify(data.error)}`
: response.statusText || `HTTP error ${response.status}`
: undefined
if (!response.ok) {
logger.error(`[${requestId}] External API error: ${response.status} ${response.statusText}`)
}
return formatResponse({
success: response.ok,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
data,
error: errorMessage,
})
} catch (error: any) {
logger.error(`[${requestId}] Proxy GET request failed`, {
url: targetUrl,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return createErrorResponse(error)
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const startTime = new Date()
const startTimeISO = startTime.toISOString()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.error(`[${requestId}] Authentication failed for proxy:`, authResult.error)
return createErrorResponse('Unauthorized', 401)
}
let requestBody
try {
requestBody = await request.json()
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse request body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
throw new Error('Invalid JSON in request body')
}
const validationResult = proxyPostSchema.safeParse(requestBody)
if (!validationResult.success) {
logger.error(`[${requestId}] Request validation failed`, {
errors: validationResult.error.errors,
})
const errorMessages = validationResult.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
throw new Error(`Validation failed: ${errorMessages}`)
}
const { toolId, params } = validationResult.data
logger.info(`[${requestId}] Processing tool: ${toolId}`)
const tool = getTool(toolId)
if (!tool) {
logger.error(`[${requestId}] Tool not found: ${toolId}`)
throw new Error(`Tool not found: ${toolId}`)
}
try {
validateRequiredParametersAfterMerge(toolId, tool, params)
} catch (validationError) {
logger.warn(`[${requestId}] Tool validation failed for ${toolId}`, {
error: validationError instanceof Error ? validationError.message : String(validationError),
})
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return createErrorResponse(validationError, 400, {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
})
}
const hasFileOutputs =
tool.outputs &&
Object.values(tool.outputs).some(
(output) => output.type === 'file' || output.type === 'file[]'
)
const result = await executeTool(
toolId,
params,
true, // skipProxy (we're already in the proxy)
!hasFileOutputs, // skipPostProcess (don't skip if tool has file outputs)
undefined // execution context is not available in proxy context
)
if (!result.success) {
logger.warn(`[${requestId}] Tool execution failed for ${toolId}`, {
error: result.error || 'Unknown error',
})
throw new Error(result.error || 'Tool execution failed')
}
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
const responseWithTimingData = {
...result,
startTime: startTimeISO,
endTime: endTimeISO,
duration,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`)
return formatResponse(responseWithTimingData)
} catch (error: any) {
logger.error(`[${requestId}] Proxy request failed`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
})
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return createErrorResponse(error, 500, {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
})
}
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
})
}

View File

@@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { StorageService } from '@/lib/uploads'
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -47,13 +51,13 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Mistral parse request`, {
filePath: validatedData.filePath,
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
userId,
})
let fileUrl = validatedData.filePath
if (validatedData.filePath?.includes('/api/files/serve/')) {
if (isInternalFileUrl(validatedData.filePath)) {
try {
const storageKey = extractStorageKey(validatedData.filePath)

View File

@@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { StorageService } from '@/lib/uploads'
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -48,13 +52,13 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Pulse parse request`, {
filePath: validatedData.filePath,
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
userId,
})
let fileUrl = validatedData.filePath
if (validatedData.filePath?.includes('/api/files/serve/')) {
if (isInternalFileUrl(validatedData.filePath)) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)

View File

@@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { StorageService } from '@/lib/uploads'
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -44,13 +48,13 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Reducto parse request`, {
filePath: validatedData.filePath,
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
userId,
})
let fileUrl = validatedData.filePath
if (validatedData.filePath?.includes('/api/files/serve/')) {
if (isInternalFileUrl(validatedData.filePath)) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)

View File

@@ -79,11 +79,13 @@ export async function POST(request: NextRequest) {
// Generate public URL for destination (properly encode the destination key)
const encodedDestKey = validatedData.destinationKey.split('/').map(encodeURIComponent).join('/')
const url = `https://${validatedData.destinationBucket}.s3.${validatedData.region}.amazonaws.com/${encodedDestKey}`
const uri = `s3://${validatedData.destinationBucket}/${validatedData.destinationKey}`
return NextResponse.json({
success: true,
output: {
url,
uri,
copySourceVersionId: result.CopySourceVersionId,
versionId: result.VersionId,
etag: result.CopyObjectResult?.ETag,

View File

@@ -117,11 +117,13 @@ export async function POST(request: NextRequest) {
const encodedKey = validatedData.objectKey.split('/').map(encodeURIComponent).join('/')
const url = `https://${validatedData.bucketName}.s3.${validatedData.region}.amazonaws.com/${encodedKey}`
const uri = `s3://${validatedData.bucketName}/${validatedData.objectKey}`
return NextResponse.json({
success: true,
output: {
url,
uri,
etag: result.ETag,
location: url,
key: validatedData.objectKey,

View File

@@ -0,0 +1,637 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
validateAwsRegion,
validateExternalUrl,
validateS3BucketName,
} from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { StorageService } from '@/lib/uploads'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
const logger = createLogger('TextractParseAPI')
const QuerySchema = z.object({
Text: z.string().min(1),
Alias: z.string().optional(),
Pages: z.array(z.string()).optional(),
})
const TextractParseSchema = z
.object({
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
region: z.string().min(1, 'AWS region is required'),
processingMode: z.enum(['sync', 'async']).optional().default('sync'),
filePath: z.string().optional(),
s3Uri: z.string().optional(),
featureTypes: z
.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT']))
.optional(),
queries: z.array(QuerySchema).optional(),
})
.superRefine((data, ctx) => {
const regionValidation = validateAwsRegion(data.region, 'AWS region')
if (!regionValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: regionValidation.error,
path: ['region'],
})
}
})
function getSignatureKey(
key: string,
dateStamp: string,
regionName: string,
serviceName: string
): Buffer {
const kDate = crypto.createHmac('sha256', `AWS4${key}`).update(dateStamp).digest()
const kRegion = crypto.createHmac('sha256', kDate).update(regionName).digest()
const kService = crypto.createHmac('sha256', kRegion).update(serviceName).digest()
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
return kSigning
}
function signAwsRequest(
method: string,
host: string,
uri: string,
body: string,
accessKeyId: string,
secretAccessKey: string,
region: string,
service: string,
amzTarget: string
): Record<string, string> {
const date = new Date()
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)
const payloadHash = crypto.createHash('sha256').update(body).digest('hex')
const canonicalHeaders =
`content-type:application/x-amz-json-1.1\n` +
`host:${host}\n` +
`x-amz-date:${amzDate}\n` +
`x-amz-target:${amzTarget}\n`
const signedHeaders = 'content-type;host;x-amz-date;x-amz-target'
const canonicalRequest = `${method}\n${uri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`
const algorithm = 'AWS4-HMAC-SHA256'
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`
const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto.createHash('sha256').update(canonicalRequest).digest('hex')}`
const signingKey = getSignatureKey(secretAccessKey, dateStamp, region, service)
const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex')
const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
return {
'Content-Type': 'application/x-amz-json-1.1',
Host: host,
'X-Amz-Date': amzDate,
'X-Amz-Target': amzTarget,
Authorization: authorizationHeader,
}
}
async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch document: ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
const bytes = Buffer.from(arrayBuffer).toString('base64')
const contentType = response.headers.get('content-type') || 'application/octet-stream'
return { bytes, contentType }
}
function parseS3Uri(s3Uri: string): { bucket: string; key: string } {
const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/)
if (!match) {
throw new Error(
`Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object`
)
}
const bucket = match[1]
const key = match[2]
const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name')
if (!bucketValidation.isValid) {
throw new Error(bucketValidation.error)
}
if (key.includes('..') || key.startsWith('/')) {
throw new Error('S3 key contains invalid path traversal sequences')
}
return { bucket, key }
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function callTextractAsync(
host: string,
amzTarget: string,
body: Record<string, unknown>,
accessKeyId: string,
secretAccessKey: string,
region: string
): Promise<Record<string, unknown>> {
const bodyString = JSON.stringify(body)
const headers = signAwsRequest(
'POST',
host,
'/',
bodyString,
accessKeyId,
secretAccessKey,
region,
'textract',
amzTarget
)
const response = await fetch(`https://${host}/`, {
method: 'POST',
headers,
body: bodyString,
})
if (!response.ok) {
const errorText = await response.text()
let errorMessage = `Textract API error: ${response.statusText}`
try {
const errorJson = JSON.parse(errorText)
if (errorJson.Message) {
errorMessage = errorJson.Message
} else if (errorJson.__type) {
errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}`
}
} catch {
// Use default error message
}
throw new Error(errorMessage)
}
return response.json()
}
async function pollForJobCompletion(
host: string,
jobId: string,
accessKeyId: string,
secretAccessKey: string,
region: string,
useAnalyzeDocument: boolean,
requestId: string
): Promise<Record<string, unknown>> {
const pollIntervalMs = 5000 // 5 seconds between polls
const maxPollTimeMs = 180000 // 3 minutes maximum polling time
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
const getTarget = useAnalyzeDocument
? 'Textract.GetDocumentAnalysis'
: 'Textract.GetDocumentTextDetection'
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await callTextractAsync(
host,
getTarget,
{ JobId: jobId },
accessKeyId,
secretAccessKey,
region
)
const jobStatus = result.JobStatus as string
if (jobStatus === 'SUCCEEDED') {
logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`)
let allBlocks = (result.Blocks as unknown[]) || []
let nextToken = result.NextToken as string | undefined
while (nextToken) {
const nextResult = await callTextractAsync(
host,
getTarget,
{ JobId: jobId, NextToken: nextToken },
accessKeyId,
secretAccessKey,
region
)
allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || [])
nextToken = nextResult.NextToken as string | undefined
}
return {
...result,
Blocks: allBlocks,
}
}
if (jobStatus === 'FAILED') {
throw new Error(`Textract job failed: ${result.StatusMessage || 'Unknown error'}`)
}
if (jobStatus === 'PARTIAL_SUCCESS') {
logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`)
let allBlocks = (result.Blocks as unknown[]) || []
let nextToken = result.NextToken as string | undefined
while (nextToken) {
const nextResult = await callTextractAsync(
host,
getTarget,
{ JobId: jobId, NextToken: nextToken },
accessKeyId,
secretAccessKey,
region
)
allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || [])
nextToken = nextResult.NextToken as string | undefined
}
return {
...result,
Blocks: allBlocks,
}
}
logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`)
await sleep(pollIntervalMs)
}
throw new Error(
`Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`
)
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{
success: false,
error: authResult.error || 'Unauthorized',
},
{ status: 401 }
)
}
const userId = authResult.userId
const body = await request.json()
const validatedData = TextractParseSchema.parse(body)
const processingMode = validatedData.processingMode || 'sync'
const featureTypes = validatedData.featureTypes ?? []
const useAnalyzeDocument = featureTypes.length > 0
const host = `textract.${validatedData.region}.amazonaws.com`
logger.info(`[${requestId}] Textract parse request`, {
processingMode,
filePath: validatedData.filePath?.substring(0, 50),
s3Uri: validatedData.s3Uri?.substring(0, 50),
featureTypes,
userId,
})
if (processingMode === 'async') {
if (!validatedData.s3Uri) {
return NextResponse.json(
{
success: false,
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
},
{ status: 400 }
)
}
const { bucket: s3Bucket, key: s3Key } = parseS3Uri(validatedData.s3Uri)
logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket, s3Key })
const startTarget = useAnalyzeDocument
? 'Textract.StartDocumentAnalysis'
: 'Textract.StartDocumentTextDetection'
const startBody: Record<string, unknown> = {
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Name: s3Key,
},
},
}
if (useAnalyzeDocument) {
startBody.FeatureTypes = featureTypes
if (
validatedData.queries &&
validatedData.queries.length > 0 &&
featureTypes.includes('QUERIES')
) {
startBody.QueriesConfig = {
Queries: validatedData.queries.map((q) => ({
Text: q.Text,
Alias: q.Alias,
Pages: q.Pages,
})),
}
}
}
const startResult = await callTextractAsync(
host,
startTarget,
startBody,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region
)
const jobId = startResult.JobId as string
if (!jobId) {
throw new Error('Failed to start Textract job: No JobId returned')
}
logger.info(`[${requestId}] Async job started`, { jobId })
const textractData = await pollForJobCompletion(
host,
jobId,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region,
useAnalyzeDocument,
requestId
)
logger.info(`[${requestId}] Textract async parse successful`, {
pageCount: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0,
blockCount: (textractData.Blocks as unknown[])?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: textractData.Blocks ?? [],
documentMetadata: {
pages: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0,
},
modelVersion: (textractData.AnalyzeDocumentModelVersion ??
textractData.DetectDocumentTextModelVersion) as string | undefined,
},
})
}
if (!validatedData.filePath) {
return NextResponse.json(
{
success: false,
error: 'File path is required for single-page processing',
},
{ status: 400 }
)
}
let fileUrl = validatedData.filePath
const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath)
if (isInternalFilePath) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: storageKey,
context,
})
return NextResponse.json(
{
success: false,
error: 'File not found',
},
{ status: 404 }
)
}
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
} catch (error) {
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
return NextResponse.json(
{
success: false,
error: 'Failed to generate file access URL',
},
{ status: 500 }
)
}
} else if (validatedData.filePath?.startsWith('/')) {
// Reject arbitrary absolute paths that don't contain /api/files/serve/
logger.warn(`[${requestId}] Invalid internal path`, {
userId,
path: validatedData.filePath.substring(0, 50),
})
return NextResponse.json(
{
success: false,
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
},
{ status: 400 }
)
} else {
const urlValidation = validateExternalUrl(fileUrl, 'Document URL')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] SSRF attempt blocked`, {
userId,
url: fileUrl.substring(0, 100),
error: urlValidation.error,
})
return NextResponse.json(
{
success: false,
error: urlValidation.error,
},
{ status: 400 }
)
}
}
const { bytes, contentType } = await fetchDocumentBytes(fileUrl)
// Track if this is a PDF for better error messaging
const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf')
const uri = '/'
let textractBody: Record<string, unknown>
let amzTarget: string
if (useAnalyzeDocument) {
amzTarget = 'Textract.AnalyzeDocument'
textractBody = {
Document: {
Bytes: bytes,
},
FeatureTypes: featureTypes,
}
if (
validatedData.queries &&
validatedData.queries.length > 0 &&
featureTypes.includes('QUERIES')
) {
textractBody.QueriesConfig = {
Queries: validatedData.queries.map((q) => ({
Text: q.Text,
Alias: q.Alias,
Pages: q.Pages,
})),
}
}
} else {
amzTarget = 'Textract.DetectDocumentText'
textractBody = {
Document: {
Bytes: bytes,
},
}
}
const bodyString = JSON.stringify(textractBody)
const headers = signAwsRequest(
'POST',
host,
uri,
bodyString,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region,
'textract',
amzTarget
)
const textractResponse = await fetch(`https://${host}${uri}`, {
method: 'POST',
headers,
body: bodyString,
})
if (!textractResponse.ok) {
const errorText = await textractResponse.text()
logger.error(`[${requestId}] Textract API error:`, errorText)
let errorMessage = `Textract API error: ${textractResponse.statusText}`
let isUnsupportedFormat = false
try {
const errorJson = JSON.parse(errorText)
if (errorJson.Message) {
errorMessage = errorJson.Message
} else if (errorJson.__type) {
errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}`
}
// Check for unsupported document format error
isUnsupportedFormat =
errorJson.__type === 'UnsupportedDocumentException' ||
errorJson.Message?.toLowerCase().includes('unsupported document') ||
errorText.toLowerCase().includes('unsupported document')
} catch {
isUnsupportedFormat = errorText.toLowerCase().includes('unsupported document')
}
// Provide helpful message for unsupported format (likely multi-page PDF)
if (isUnsupportedFormat && isPdf) {
errorMessage =
'This document format is not supported in Single Page mode. If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.'
}
return NextResponse.json(
{
success: false,
error: errorMessage,
},
{ status: textractResponse.status }
)
}
const textractData = await textractResponse.json()
logger.info(`[${requestId}] Textract parse successful`, {
pageCount: textractData.DocumentMetadata?.Pages ?? 0,
blockCount: textractData.Blocks?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: textractData.Blocks ?? [],
documentMetadata: {
pages: textractData.DocumentMetadata?.Pages ?? 0,
},
modelVersion:
textractData.AnalyzeDocumentModelVersion ??
textractData.DetectDocumentTextModelVersion ??
undefined,
},
})
} 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 in Textract parse:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -12,6 +12,10 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
@@ -25,7 +29,7 @@ import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
import { normalizeName } from '@/executor/constants'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
import type { StreamingExecution } from '@/executor/types'
import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
@@ -38,6 +42,8 @@ const ExecuteWorkflowSchema = z.object({
useDraftState: z.boolean().optional(),
input: z.any().optional(),
isClientSession: z.boolean().optional(),
includeFileBase64: z.boolean().optional().default(true),
base64MaxBytes: z.number().int().positive().optional(),
workflowStateOverride: z
.object({
blocks: z.record(z.any()),
@@ -214,6 +220,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState,
input: validatedInput,
isClientSession = false,
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
} = validation.data
@@ -227,6 +235,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
triggerType,
stream,
useDraftState,
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
...rest
@@ -427,16 +437,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
snapshot,
callbacks: {},
loggingSession,
includeFileBase64,
base64MaxBytes,
})
const hasResponseBlock = workflowHasResponseBlock(result)
const outputWithBase64 = includeFileBase64
? ((await hydrateUserFilesWithBase64(result.output, {
requestId,
executionId,
maxBytes: base64MaxBytes,
})) as NormalizedBlockOutput)
: result.output
const resultWithBase64 = { ...result, output: outputWithBase64 }
// Cleanup base64 cache for this execution
await cleanupExecutionBase64Cache(executionId)
const hasResponseBlock = workflowHasResponseBlock(resultWithBase64)
if (hasResponseBlock) {
return createHttpResponseFromBlock(result)
return createHttpResponseFromBlock(resultWithBase64)
}
const filteredResult = {
success: result.success,
output: result.output,
output: outputWithBase64,
error: result.error,
metadata: result.metadata
? {
@@ -498,6 +523,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: false,
workflowTriggerType: triggerType === 'chat' ? 'chat' : 'api',
includeFileBase64,
base64MaxBytes,
},
executionId,
})
@@ -698,6 +725,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
},
loggingSession,
abortSignal: abortController.signal,
includeFileBase64,
base64MaxBytes,
})
if (result.status === 'paused') {
@@ -750,12 +779,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
data: {
success: result.success,
output: result.output,
output: includeFileBase64
? await hydrateUserFilesWithBase64(result.output, {
requestId,
executionId,
maxBytes: base64MaxBytes,
})
: result.output,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
},
})
// Cleanup base64 cache for this execution
await cleanupExecutionBase64Cache(executionId)
} catch (error: any) {
const errorMessage = error.message || 'Unknown error'
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`)

View File

@@ -2,7 +2,7 @@
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { isUserFile } from '@/lib/core/utils/display-filters'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
@@ -17,7 +17,7 @@ function extractFilesFromData(
return files
}
if (isUserFile(data)) {
if (isUserFileWithMetadata(data)) {
if (!seenIds.has(data.id)) {
seenIds.add(data.id)
files.push({
@@ -232,7 +232,7 @@ export function useChatStreaming() {
return null
}
if (isUserFile(value)) {
if (isUserFileWithMetadata(value)) {
return null
}
@@ -285,7 +285,7 @@ export function useChatStreaming() {
const value = getOutputValue(blockOutputs, config.path)
if (isUserFile(value)) {
if (isUserFileWithMetadata(value)) {
extractedFiles.push({
id: value.id,
name: value.name,

View File

@@ -234,7 +234,7 @@ function ProgressBar({
{segments.map((segment, index) => (
<div
key={index}
className='absolute h-full'
className='absolute h-full opacity-70'
style={{
left: `${segment.startPercent}%`,
width: `${segment.widthPercent}%`,

View File

@@ -257,7 +257,7 @@ export const LogDetails = memo(function LogDetails({
Version
</span>
<div className='flex w-0 flex-1 justify-end'>
<span className='max-w-full truncate rounded-[6px] bg-[#14291B] px-[9px] py-[2px] font-medium text-[#86EFAC] text-[12px]'>
<span className='max-w-full truncate rounded-[6px] bg-[#bbf7d0] px-[9px] py-[2px] font-medium text-[#15803d] text-[12px] dark:bg-[#14291B] dark:text-[#86EFAC]'>
{log.deploymentVersionName || `v${log.deploymentVersion}`}
</span>
</div>

View File

@@ -19,6 +19,7 @@ import { DatePicker } from '@/components/emcn/components/date-picker/date-picker
import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
@@ -211,12 +212,12 @@ export function LogsToolbar({
}, [level])
const statusOptions: ComboboxOption[] = useMemo(
() => [
{ value: 'error', label: 'Error', icon: getColorIcon('var(--text-error)') },
{ value: 'info', label: 'Info', icon: getColorIcon('var(--terminal-status-info-color)') },
{ value: 'running', label: 'Running', icon: getColorIcon('#22c55e') },
{ value: 'pending', label: 'Pending', icon: getColorIcon('#f59e0b') },
],
() =>
(Object.keys(STATUS_CONFIG) as LogStatus[]).map((status) => ({
value: status,
label: STATUS_CONFIG[status].label,
icon: getColorIcon(STATUS_CONFIG[status].color),
})),
[]
)
@@ -242,12 +243,8 @@ export function LogsToolbar({
const selectedStatusColor = useMemo(() => {
if (selectedStatuses.length !== 1) return null
const status = selectedStatuses[0]
if (status === 'error') return 'var(--text-error)'
if (status === 'info') return 'var(--terminal-status-info-color)'
if (status === 'running') return '#22c55e'
if (status === 'pending') return '#f59e0b'
return null
const status = selectedStatuses[0] as LogStatus
return STATUS_CONFIG[status]?.color ?? null
}, [selectedStatuses])
const workflowOptions: ComboboxOption[] = useMemo(

View File

@@ -5,7 +5,6 @@ import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
/** Column configuration for logs table - shared between header and rows */
export const LOG_COLUMNS = {
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
@@ -16,10 +15,8 @@ export const LOG_COLUMNS = {
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
} as const
/** Type-safe column key derived from LOG_COLUMNS */
export type LogColumnKey = keyof typeof LOG_COLUMNS
/** Ordered list of column keys for rendering table headers */
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'date',
'time',
@@ -30,7 +27,6 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'duration',
] as const
/** Possible execution status values for workflow logs */
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
/**
@@ -53,30 +49,28 @@ export function getDisplayStatus(status: string | null | undefined): LogStatus {
}
}
/** Configuration mapping log status to Badge variant and display label */
const STATUS_VARIANT_MAP: Record<
export const STATUS_CONFIG: Record<
LogStatus,
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string }
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string; color: string }
> = {
error: { variant: 'red', label: 'Error' },
pending: { variant: 'amber', label: 'Pending' },
running: { variant: 'green', label: 'Running' },
cancelled: { variant: 'gray', label: 'Cancelled' },
info: { variant: 'gray', label: 'Info' },
error: { variant: 'red', label: 'Error', color: 'var(--text-error)' },
pending: { variant: 'amber', label: 'Pending', color: '#f59e0b' },
running: { variant: 'green', label: 'Running', color: '#22c55e' },
cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316' },
info: { variant: 'gray', label: 'Info', color: 'var(--terminal-status-info-color)' },
}
/** Configuration mapping core trigger types to Badge color variants */
const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['variant']> = {
manual: 'gray-secondary',
api: 'blue',
schedule: 'green',
chat: 'purple',
webhook: 'orange',
mcp: 'cyan',
a2a: 'teal',
}
interface StatusBadgeProps {
/** The execution status to display */
status: LogStatus
}
@@ -86,14 +80,13 @@ interface StatusBadgeProps {
* @returns A Badge with dot indicator and status label
*/
export const StatusBadge = React.memo(({ status }: StatusBadgeProps) => {
const config = STATUS_VARIANT_MAP[status]
const config = STATUS_CONFIG[status]
return React.createElement(Badge, { variant: config.variant, dot: true }, config.label)
})
StatusBadge.displayName = 'StatusBadge'
interface TriggerBadgeProps {
/** The trigger type identifier (e.g., 'manual', 'api', or integration block type) */
trigger: string
}

View File

@@ -142,7 +142,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && !isResponseBlock && !isSubflowBlock && (
{!isStartBlock && !isResponseBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -213,6 +213,29 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -26,7 +26,6 @@ export interface CanvasMenuProps {
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
@@ -55,15 +54,12 @@ export function CanvasMenu({
onOpenLogs,
onToggleVariables,
onToggleChat,
onInvite,
isVariablesOpen = false,
isChatOpen = false,
hasClipboard = false,
disableEdit = false,
disableAdmin = false,
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: CanvasMenuProps) {
return (
<Popover
@@ -179,22 +175,6 @@ export function CanvasMenu({
>
{isChatOpen ? 'Close Chat' : 'Open Chat'}
</PopoverItem>
{/* Admin action - hidden when invitations are disabled */}
{!isInvitationsDisabled && (
<>
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>
)

View File

@@ -886,17 +886,16 @@ export function Chat() {
onMouseDown={(e) => e.stopPropagation()}
>
{shouldShowConfigureStartInputsButton && (
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-[2px] font-medium font-sans text-[12px] text-[var(--text-primary)] hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]'
title='Add chat inputs to Start block'
onMouseDown={(e) => {
e.stopPropagation()
handleConfigureStartInputs()
}}
>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
<span className='whitespace-nowrap'>Add inputs</span>
</div>
)}
<OutputSelect

View File

@@ -129,10 +129,6 @@ export function OutputSelect({
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
const operationValue =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.operation?.value
: subBlockValues?.[block.id]?.operation
let outputsToProcess: Record<string, unknown> = {}
@@ -146,10 +142,20 @@ export function OutputSelect({
outputsToProcess = blockConfig?.outputs || {}
}
} else {
const toolOutputs =
blockConfig && typeof operationValue === 'string'
? getToolOutputs(blockConfig, operationValue)
: {}
// Build subBlocks object for tool selector
const rawSubBlockValues =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks
: subBlockValues?.[block.id]
const subBlocks: Record<string, { value: unknown }> = {}
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
for (const [key, val] of Object.entries(rawSubBlockValues)) {
// Handle both { value: ... } and raw value formats
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
}
}
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, subBlocks) : {}
outputsToProcess =
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
}

View File

@@ -1510,8 +1510,7 @@ export function ToolCall({
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond' ||
toolCall.name === 'deploy_respond'
toolCall.name === 'plan_respond'
)
return null

View File

@@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger'
import { Check, Clipboard } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
@@ -883,14 +882,13 @@ console.log(data);`
<code className='text-[10px]'>&lt;start.files&gt;</code>.
</p>
{missingFields.any && (
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-[2px] font-medium font-sans text-[12px] text-[var(--text-primary)] hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
<span className='whitespace-nowrap'>Add inputs</span>
</div>
)}
</div>
</div>

View File

@@ -8,9 +8,10 @@ import { Button, Combobox } from '@/components/emcn/components'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
const logger = createLogger('FileUpload')
@@ -85,14 +86,47 @@ export function FileUpload({
}
}
/**
* Checks if a file's MIME type matches the accepted types
* Supports exact matches, wildcard patterns (e.g., 'image/*'), and '*' for all types
*/
const isFileTypeAccepted = (fileType: string | undefined, accepted: string): boolean => {
if (accepted === '*') return true
if (!fileType) return false
const acceptedList = accepted.split(',').map((t) => t.trim().toLowerCase())
const normalizedFileType = fileType.toLowerCase()
return acceptedList.some((acceptedType) => {
if (acceptedType === normalizedFileType) return true
if (acceptedType.endsWith('/*')) {
const typePrefix = acceptedType.slice(0, -1) // 'image/' from 'image/*'
return normalizedFileType.startsWith(typePrefix)
}
if (acceptedType.startsWith('.')) {
const extension = acceptedType.slice(1).toLowerCase()
const fileExtension = getExtensionFromMimeType(normalizedFileType)
if (fileExtension === extension) return true
return normalizedFileType.endsWith(`/${extension}`)
}
return false
})
}
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
return !existingFiles.some(
const isAlreadySelected = existingFiles.some(
(existing) =>
existing.name === workspaceFile.name ||
existing.path?.includes(workspaceFile.key) ||
existing.key === workspaceFile.key
)
return !isAlreadySelected
})
useEffect(() => {
@@ -421,23 +455,23 @@ export function FileUpload({
return (
<div
key={fileKey}
className='flex items-center justify-between rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]'
className='relative rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]'
>
<div className='flex-1 truncate pr-2 text-sm' title={file.name}>
<div className='truncate pr-[24px] text-sm' title={file.name}>
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
</div>
<Button
type='button'
variant='ghost'
className='h-5 w-5 shrink-0 p-0'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-6 w-6 p-0'
onClick={(e) => handleRemoveFile(file, e)}
disabled={isDeleting}
>
{isDeleting ? (
<div className='h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<X className='h-3.5 w-3.5' />
<X className='h-4 w-4 opacity-50' />
)}
</Button>
</div>
@@ -468,19 +502,30 @@ export function FileUpload({
const comboboxOptions = useMemo(
() => [
{ label: 'Upload New File', value: '__upload_new__' },
...availableWorkspaceFiles.map((file) => ({
label: file.name,
value: file.id,
})),
...availableWorkspaceFiles.map((file) => {
const isAccepted =
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
return {
label: file.name,
value: file.id,
disabled: !isAccepted,
}
}),
],
[availableWorkspaceFiles]
[availableWorkspaceFiles, acceptedTypes]
)
const handleComboboxChange = (value: string) => {
setInputValue(value)
const isValidOption =
value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
const selectedFile = availableWorkspaceFiles.find((file) => file.id === value)
const isAcceptedType =
selectedFile &&
(!acceptedTypes ||
acceptedTypes === '*' ||
isFileTypeAccepted(selectedFile.type, acceptedTypes))
const isValidOption = value === '__upload_new__' || isAcceptedType
if (!isValidOption) {
return

View File

@@ -28,6 +28,7 @@ interface Field {
name: string
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
value?: string
description?: string
collapsed?: boolean
}
@@ -41,7 +42,9 @@ interface FieldFormatProps {
placeholder?: string
showType?: boolean
showValue?: boolean
showDescription?: boolean
valuePlaceholder?: string
descriptionPlaceholder?: string
config?: any
}
@@ -73,6 +76,7 @@ const createDefaultField = (): Field => ({
name: '',
type: 'string',
value: '',
description: '',
collapsed: false,
})
@@ -93,7 +97,9 @@ export function FieldFormat({
placeholder = 'fieldName',
showType = true,
showValue = false,
showDescription = false,
valuePlaceholder = 'Enter default value',
descriptionPlaceholder = 'Describe this field',
}: FieldFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<Field[]>(blockId, subBlockId)
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
@@ -554,6 +560,18 @@ export function FieldFormat({
</div>
)}
{showDescription && (
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input
value={field.description ?? ''}
onChange={(e) => updateField(field.id, 'description', e.target.value)}
placeholder={descriptionPlaceholder}
disabled={isReadOnly}
/>
</div>
)}
{showValue && (
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
@@ -568,8 +586,10 @@ export function FieldFormat({
)
}
export function InputFormat(props: Omit<FieldFormatProps, 'title' | 'placeholder'>) {
return <FieldFormat {...props} title='Input' placeholder='firstName' />
export function InputFormat(
props: Omit<FieldFormatProps, 'title' | 'placeholder' | 'showDescription'>
) {
return <FieldFormat {...props} title='Input' placeholder='firstName' showDescription={true} />
}
export function ResponseFormat(

View File

@@ -241,13 +241,16 @@ const getOutputTypeForPath = (
const blockState = useWorkflowStore.getState().blocks[blockId]
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
return getBlockOutputType(block.type, outputPath, subBlocks)
} else {
const operationValue = getSubBlockValue(blockId, 'operation')
if (blockConfig && operationValue) {
return getToolOutputType(blockConfig, operationValue, outputPath)
}
} else if (blockConfig?.tools?.config?.tool) {
const blockState = useWorkflowStore.getState().blocks[blockId]
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
return getToolOutputType(blockConfig, subBlocks, outputPath)
}
return 'any'
const subBlocks =
mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks
const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled
return getBlockOutputType(block?.type ?? '', outputPath, subBlocks, triggerMode)
}
/**
@@ -1211,11 +1214,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
: allTags
}
} else {
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
const toolOutputPaths = operationValue
? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks)
: []
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
if (toolOutputPaths.length > 0) {
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
@@ -1535,7 +1534,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
// For self-reference, only show url and resumeEndpoint (not response format fields)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
@@ -1543,11 +1541,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
}
} else {
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
const toolOutputPaths = operationValue
? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks)
: []
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
if (toolOutputPaths.length > 0) {
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
@@ -1789,7 +1783,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
mergedSubBlocks
)
if (fieldType === 'files' || fieldType === 'array') {
if (fieldType === 'files' || fieldType === 'file[]' || fieldType === 'array') {
const blockName = parts[0]
const remainingPath = parts.slice(2).join('.')
processedTag = `${blockName}.${arrayFieldName}[0].${remainingPath}`

View File

@@ -1,6 +1,7 @@
import { memo, useMemo, useRef } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Badge } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
@@ -78,6 +79,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
? currentBlock.is_diff
: undefined
const isEnabled = currentBlock?.enabled ?? true
const isPreview = data?.isPreview || false
// Focus state
@@ -184,14 +186,21 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }}
style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span className='font-medium text-[16px]' title={blockName}>
<span
className={cn(
'truncate font-medium text-[16px]',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={blockName}
>
{blockName}
</span>
</div>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
</div>
{!isPreview && (

View File

@@ -2,13 +2,15 @@
import { useMemo } from 'react'
import { extractFieldsFromSchema } from '@/lib/core/utils/response-format'
import { getBlockOutputPaths, getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import {
getBlockOutputPaths,
getBlockOutputs,
getToolOutputs,
} from '@/lib/workflows/blocks/block-outputs'
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
import type { SchemaField } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item'
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTool } from '@/tools/utils'
const RESERVED_KEYS = new Set(['type', 'description'])
@@ -24,64 +26,6 @@ const getSubBlockValue = (blockId: string, property: string): any => {
return useSubBlockStore.getState().getValue(blockId, property)
}
/**
* Generates output paths for a tool-based block
*/
const generateToolOutputPaths = (blockConfig: BlockConfig, operation: string): string[] => {
if (!blockConfig?.tools?.config?.tool) return []
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return []
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return []
return generateOutputPaths(toolConfig.outputs)
} catch {
return []
}
}
/**
* Recursively generates all output paths from an outputs schema
*/
const generateOutputPaths = (outputs: Record<string, any>, prefix = ''): string[] => {
const paths: string[] = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
paths.push(currentPath)
// Handle nested objects and arrays
if (value.type === 'object' && value.properties) {
paths.push(...generateOutputPaths(value.properties, currentPath))
} else if (value.type === 'array' && value.items?.properties) {
paths.push(...generateOutputPaths(value.items.properties, currentPath))
} else if (
value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items)
) {
paths.push(...generateOutputPaths(value.items, currentPath))
}
} else {
const subPaths = generateOutputPaths(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push(currentPath)
}
}
return paths
}
/**
* Extracts nested fields from array or object properties
*/
@@ -155,26 +99,6 @@ const createFieldFromOutput = (
return field
}
/**
* Gets tool outputs for a block's operation
*/
const getToolOutputs = (
blockConfig: BlockConfig | null,
operation?: string
): Record<string, any> => {
if (!blockConfig?.tools?.config?.tool || !operation) return {}
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return {}
const toolConfig = getTool(toolId)
return toolConfig?.outputs || {}
} catch {
return {}
}
}
interface UseBlockOutputFieldsParams {
blockId: string
blockType: string
@@ -299,14 +223,11 @@ export function useBlockOutputFields({
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks)
} else {
// For tool-based blocks, try to get tool outputs first
const operationValue =
operation ?? mergedSubBlocks?.operation?.value ?? getSubBlockValue(blockId, 'operation')
const toolOutputs = operationValue ? getToolOutputs(blockConfig, operationValue) : {}
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, mergedSubBlocks) : {}
if (Object.keys(toolOutputs).length > 0) {
baseOutputs = toolOutputs
} else {
// Use getBlockOutputs which handles inputFormat merging
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode)
}
}

View File

@@ -3323,15 +3323,12 @@ const WorkflowContent = React.memo(() => {
onOpenLogs={handleContextOpenLogs}
onToggleVariables={handleContextToggleVariables}
onToggleChat={handleContextToggleChat}
onInvite={handleContextInvite}
isVariablesOpen={isVariablesOpen}
isChatOpen={isChatOpen}
hasClipboard={hasClipboard()}
disableEdit={!effectivePermissions.canEdit}
disableAdmin={!effectivePermissions.canAdmin}
canUndo={canUndo}
canRedo={canRedo}
isInvitationsDisabled={isInvitationsDisabled}
/>
</>
)}

View File

@@ -208,6 +208,8 @@ async function runWorkflowExecution({
snapshot,
callbacks: {},
loggingSession,
includeFileBase64: true,
base64MaxBytes: undefined,
})
if (executionResult.status === 'paused') {

View File

@@ -240,6 +240,8 @@ async function executeWebhookJobInternal(
snapshot,
callbacks: {},
loggingSession,
includeFileBase64: true, // Enable base64 hydration
base64MaxBytes: undefined, // Use default limit
})
if (executionResult.status === 'paused') {
@@ -493,6 +495,7 @@ async function executeWebhookJobInternal(
snapshot,
callbacks: {},
loggingSession,
includeFileBase64: true,
})
if (executionResult.status === 'paused') {

View File

@@ -109,6 +109,8 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
snapshot,
callbacks: {},
loggingSession,
includeFileBase64: true,
base64MaxBytes: undefined,
})
if (result.status === 'paused') {

View File

@@ -107,14 +107,26 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
condition: { field: 'operation', value: 'a2a_send_message' },
},
{
id: 'files',
id: 'fileUpload',
title: 'Files',
type: 'file-upload',
canonicalParamId: 'files',
placeholder: 'Upload files to send',
description: 'Files to include with the message (FilePart)',
condition: { field: 'operation', value: 'a2a_send_message' },
mode: 'basic',
multiple: true,
},
{
id: 'fileReference',
title: 'Files',
type: 'short-input',
canonicalParamId: 'files',
placeholder: 'Reference files from previous blocks',
description: 'Files to include with the message (FilePart)',
condition: { field: 'operation', value: 'a2a_send_message' },
mode: 'advanced',
},
{
id: 'taskId',
title: 'Task ID',
@@ -202,6 +214,15 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
],
config: {
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: {
@@ -233,6 +254,14 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
type: 'array',
description: 'Files to include with the message',
},
fileUpload: {
type: 'array',
description: 'Uploaded files (basic mode)',
},
fileReference: {
type: 'json',
description: 'File reference from previous blocks (advanced mode)',
},
historyLength: {
type: 'number',
description: 'Number of history messages to include',

View File

@@ -5,8 +5,9 @@ import type { ConfluenceResponse } from '@/tools/confluence/types'
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
type: 'confluence',
name: 'Confluence',
name: 'Confluence (Legacy)',
description: 'Interact with Confluence',
hideFromToolbar: true,
authMode: AuthMode.OAuth,
longDescription:
'Integrate Confluence into the workflow. Can read, create, update, delete pages, manage comments, attachments, labels, and search content.',
@@ -357,3 +358,342 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
status: { type: 'string', description: 'Space status' },
},
}
export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
...ConfluenceBlock,
type: 'confluence_v2',
name: 'Confluence',
hideFromToolbar: false,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Read Page', id: 'read' },
{ label: 'Create Page', id: 'create' },
{ label: 'Update Page', id: 'update' },
{ label: 'Delete Page', id: 'delete' },
{ label: 'Search Content', id: 'search' },
{ label: 'Create Comment', id: 'create_comment' },
{ label: 'List Comments', id: 'list_comments' },
{ label: 'Update Comment', id: 'update_comment' },
{ label: 'Delete Comment', id: 'delete_comment' },
{ label: 'Upload Attachment', id: 'upload_attachment' },
{ label: 'List Attachments', id: 'list_attachments' },
{ label: 'Delete Attachment', id: 'delete_attachment' },
{ label: 'List Labels', id: 'list_labels' },
{ label: 'Get Space', id: 'get_space' },
{ label: 'List Spaces', id: 'list_spaces' },
],
value: () => 'read',
},
{
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
required: true,
},
{
id: 'credential',
title: 'Confluence Account',
type: 'oauth-input',
serviceId: 'confluence',
requiredScopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
'read:space:confluence',
'read:space-details:confluence',
'write:confluence-content',
'write:confluence-space',
'write:confluence-file',
'read:content:confluence',
'read:page:confluence',
'write:page:confluence',
'read:comment:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'read:attachment:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'delete:page:confluence',
'read:label:confluence',
'write:label:confluence',
'search:confluence',
'read:me',
'offline_access',
],
placeholder: 'Select Confluence account',
required: true,
},
{
id: 'pageId',
title: 'Select Page',
type: 'file-selector',
canonicalParamId: 'pageId',
serviceId: 'confluence',
placeholder: 'Select Confluence page',
dependsOn: ['credential', 'domain'],
mode: 'basic',
},
{
id: 'manualPageId',
title: 'Page ID',
type: 'short-input',
canonicalParamId: 'pageId',
placeholder: 'Enter Confluence page ID',
mode: 'advanced',
},
{
id: 'spaceId',
title: 'Space ID',
type: 'short-input',
placeholder: 'Enter Confluence space ID',
required: true,
condition: { field: 'operation', value: ['create', 'get_space'] },
},
{
id: 'title',
title: 'Title',
type: 'short-input',
placeholder: 'Enter title for the page',
condition: { field: 'operation', value: ['create', 'update'] },
},
{
id: 'content',
title: 'Content',
type: 'long-input',
placeholder: 'Enter content for the page',
condition: { field: 'operation', value: ['create', 'update'] },
},
{
id: 'parentId',
title: 'Parent Page ID',
type: 'short-input',
placeholder: 'Enter parent page ID (optional)',
condition: { field: 'operation', value: 'create' },
},
{
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: 'Enter search query',
required: true,
condition: { field: 'operation', value: 'search' },
},
{
id: 'comment',
title: 'Comment Text',
type: 'long-input',
placeholder: 'Enter comment text',
required: true,
condition: { field: 'operation', value: ['create_comment', 'update_comment'] },
},
{
id: 'commentId',
title: 'Comment ID',
type: 'short-input',
placeholder: 'Enter comment ID',
required: true,
condition: { field: 'operation', value: ['update_comment', 'delete_comment'] },
},
{
id: 'attachmentId',
title: 'Attachment ID',
type: 'short-input',
placeholder: 'Enter attachment ID',
required: true,
condition: { field: 'operation', value: 'delete_attachment' },
},
{
id: 'attachmentFileUpload',
title: 'File',
type: 'file-upload',
canonicalParamId: 'attachmentFile',
placeholder: 'Select file to upload',
condition: { field: 'operation', value: 'upload_attachment' },
mode: 'basic',
},
{
id: 'attachmentFileReference',
title: 'File',
type: 'short-input',
canonicalParamId: 'attachmentFile',
placeholder: 'Reference file from previous blocks',
condition: { field: 'operation', value: 'upload_attachment' },
mode: 'advanced',
},
{
id: 'attachmentFileName',
title: 'File Name',
type: 'short-input',
placeholder: 'Optional custom file name',
condition: { field: 'operation', value: 'upload_attachment' },
},
{
id: 'attachmentComment',
title: 'Comment',
type: 'short-input',
placeholder: 'Optional comment for the attachment',
condition: { field: 'operation', value: 'upload_attachment' },
},
{
id: 'labelName',
title: 'Label Name',
type: 'short-input',
placeholder: 'Enter label name',
required: true,
condition: { field: 'operation', value: ['add_label', 'remove_label'] },
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: 'Enter maximum number of results (default: 25)',
condition: {
field: 'operation',
value: ['search', 'list_comments', 'list_attachments', 'list_spaces'],
},
},
],
tools: {
access: [
'confluence_retrieve',
'confluence_update',
'confluence_create_page',
'confluence_delete_page',
'confluence_search',
'confluence_create_comment',
'confluence_list_comments',
'confluence_update_comment',
'confluence_delete_comment',
'confluence_upload_attachment',
'confluence_list_attachments',
'confluence_delete_attachment',
'confluence_list_labels',
'confluence_get_space',
'confluence_list_spaces',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'read':
return 'confluence_retrieve'
case 'create':
return 'confluence_create_page'
case 'update':
return 'confluence_update'
case 'delete':
return 'confluence_delete_page'
case 'search':
return 'confluence_search'
case 'create_comment':
return 'confluence_create_comment'
case 'list_comments':
return 'confluence_list_comments'
case 'update_comment':
return 'confluence_update_comment'
case 'delete_comment':
return 'confluence_delete_comment'
case 'upload_attachment':
return 'confluence_upload_attachment'
case 'list_attachments':
return 'confluence_list_attachments'
case 'delete_attachment':
return 'confluence_delete_attachment'
case 'list_labels':
return 'confluence_list_labels'
case 'get_space':
return 'confluence_get_space'
case 'list_spaces':
return 'confluence_list_spaces'
default:
return 'confluence_retrieve'
}
},
params: (params) => {
const {
credential,
pageId,
manualPageId,
operation,
attachmentFileUpload,
attachmentFileReference,
attachmentFile,
attachmentFileName,
attachmentComment,
...rest
} = params
const effectivePageId = (pageId || manualPageId || '').trim()
const requiresPageId = [
'read',
'update',
'delete',
'create_comment',
'list_comments',
'list_attachments',
'list_labels',
'upload_attachment',
]
const requiresSpaceId = ['create', 'get_space']
if (requiresPageId.includes(operation) && !effectivePageId) {
throw new Error('Page ID is required. Please select a page or enter a page ID manually.')
}
if (requiresSpaceId.includes(operation) && !rest.spaceId) {
throw new Error('Space ID is required for this operation.')
}
if (operation === 'upload_attachment') {
const fileInput = attachmentFileUpload || attachmentFileReference || attachmentFile
if (!fileInput) {
throw new Error('File is required for upload attachment operation.')
}
return {
credential,
pageId: effectivePageId,
operation,
file: fileInput,
fileName: attachmentFileName,
comment: attachmentComment,
...rest,
}
}
return {
credential,
pageId: effectivePageId || undefined,
operation,
...rest,
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' },
credential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier' },
manualPageId: { type: 'string', description: 'Manual page identifier' },
spaceId: { type: 'string', description: 'Space identifier' },
title: { type: 'string', description: 'Page title' },
content: { type: 'string', description: 'Page content' },
parentId: { type: 'string', description: 'Parent page identifier' },
query: { type: 'string', description: 'Search query' },
comment: { type: 'string', description: 'Comment text' },
commentId: { type: 'string', description: 'Comment identifier' },
attachmentId: { type: 'string', description: 'Attachment identifier' },
attachmentFile: { type: 'json', description: 'File to upload as attachment' },
attachmentFileUpload: { type: 'json', description: 'Uploaded file (basic mode)' },
attachmentFileReference: { type: 'json', description: 'File reference (advanced mode)' },
attachmentFileName: { type: 'string', description: 'Custom file name for attachment' },
attachmentComment: { type: 'string', description: 'Comment for the attachment' },
labelName: { type: 'string', description: 'Label name' },
limit: { type: 'number', description: 'Maximum number of results' },
},
}

View File

@@ -1,13 +1,14 @@
import { createLogger } from '@sim/logger'
import { DocumentIcon } from '@/components/icons'
import type { BlockConfig, SubBlockType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { FileParserOutput } from '@/tools/file/types'
const logger = createLogger('FileBlock')
export const FileBlock: BlockConfig<FileParserOutput> = {
type: 'file',
name: 'File',
name: 'File (Legacy)',
description: 'Read and parse multiple files',
longDescription: `Integrate File into the workflow. Can upload a file manually or insert a file url.`,
bestPractices: `
@@ -17,6 +18,7 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
category: 'tools',
bgColor: '#40916C',
icon: DocumentIcon,
hideFromToolbar: true,
subBlocks: [
{
id: 'inputMethod',
@@ -112,6 +114,99 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
fileType: { type: 'string', description: 'File type' },
file: { type: 'json', description: 'Uploaded file data' },
},
outputs: {
files: {
type: 'json',
description: 'Array of parsed file objects with content, metadata, and file properties',
},
combinedContent: {
type: 'string',
description: 'All file contents merged into a single text string',
},
processedFiles: {
type: 'files',
description: 'Array of UserFile objects for downstream use (attachments, uploads, etc.)',
},
},
}
export const FileV2Block: BlockConfig<FileParserOutput> = {
...FileBlock,
type: 'file_v2',
name: 'File',
description: 'Read and parse multiple files',
hideFromToolbar: false,
subBlocks: [
{
id: 'file',
title: 'Files',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'fileInput',
acceptedTypes:
'.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf',
placeholder: 'Upload files to process',
multiple: true,
mode: 'basic',
maxSize: 100,
},
{
id: 'filePath',
title: 'Files',
type: 'short-input' as SubBlockType,
canonicalParamId: 'fileInput',
placeholder: 'File URL',
mode: 'advanced',
},
],
tools: {
access: ['file_parser_v2'],
config: {
tool: createVersionedToolSelector({
baseToolSelector: () => 'file_parser',
suffix: '_v2',
fallbackToolId: 'file_parser_v2',
}),
params: (params) => {
const fileInput = params.file || params.filePath || params.fileInput
if (!fileInput) {
logger.error('No file input provided')
throw new Error('File is required')
}
if (typeof fileInput === 'string') {
return {
filePath: fileInput.trim(),
fileType: params.fileType || 'auto',
workspaceId: params._context?.workspaceId,
}
}
if (Array.isArray(fileInput) && fileInput.length > 0) {
const filePaths = fileInput.map((file) => file.path)
return {
filePath: filePaths.length === 1 ? filePaths[0] : filePaths,
fileType: params.fileType || 'auto',
}
}
if (fileInput?.path) {
return {
filePath: fileInput.path,
fileType: params.fileType || 'auto',
}
}
logger.error('Invalid file input format')
throw new Error('Invalid file input')
},
},
},
inputs: {
fileInput: { type: 'json', description: 'File input (upload or URL reference)' },
filePath: { type: 'string', description: 'File URL (advanced mode)' },
file: { type: 'json', description: 'Uploaded file data (basic mode)' },
fileType: { type: 'string', description: 'File type' },
},
outputs: {
files: {
type: 'json',

View File

@@ -1,11 +1,13 @@
import { MistralIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { MistralParserOutput } from '@/tools/mistral/types'
export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
type: 'mistral_parse',
name: 'Mistral Parser',
name: 'Mistral Parser (Legacy)',
description: 'Extract text from PDF documents',
hideFromToolbar: true,
authMode: AuthMode.ApiKey,
longDescription: `Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`,
docsLink: 'https://docs.sim.ai/tools/mistral_parse',
@@ -13,7 +15,6 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
bgColor: '#000000',
icon: MistralIcon,
subBlocks: [
// Show input method selection
{
id: 'inputMethod',
title: 'Select Input Method',
@@ -23,8 +24,6 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
{ id: 'upload', label: 'Upload PDF Document' },
],
},
// URL input - conditional on inputMethod
{
id: 'filePath',
title: 'PDF Document URL',
@@ -35,8 +34,6 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
value: 'url',
},
},
// File upload option
{
id: 'fileUpload',
title: 'Upload PDF',
@@ -46,9 +43,8 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
field: 'inputMethod',
value: 'upload',
},
maxSize: 50, // 50MB max via direct upload
maxSize: 50,
},
{
id: 'resultType',
title: 'Output Format',
@@ -65,28 +61,6 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
type: 'short-input',
placeholder: 'e.g. 0,1,2 (leave empty for all pages)',
},
/*
* Image-related parameters - temporarily disabled
* Uncomment if PDF image extraction is needed
*
{
id: 'includeImageBase64',
title: 'Include PDF Images',
type: 'switch',
},
{
id: 'imageLimit',
title: 'Max Images',
type: 'short-input',
placeholder: 'Maximum number of images to extract',
},
{
id: 'imageMinSize',
title: 'Min Image Size (px)',
type: 'short-input',
placeholder: 'Min width/height in pixels',
},
*/
{
id: 'apiKey',
title: 'API Key',
@@ -101,18 +75,15 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
config: {
tool: () => 'mistral_parser',
params: (params) => {
// Basic validation
if (!params || !params.apiKey || params.apiKey.trim() === '') {
throw new Error('Mistral API key is required')
}
// Build parameters object - file processing is now handled at the tool level
const parameters: any = {
const parameters: Record<string, unknown> = {
apiKey: params.apiKey.trim(),
resultType: params.resultType || 'markdown',
}
// Set filePath or fileUpload based on input method
const inputMethod = params.inputMethod || 'url'
if (inputMethod === 'url') {
if (!params.filePath || params.filePath.trim() === '') {
@@ -123,11 +94,9 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
if (!params.fileUpload) {
throw new Error('Please upload a PDF document')
}
// Pass the entire fileUpload object to the tool
parameters.fileUpload = params.fileUpload
}
// Convert pages input from string to array of numbers if provided
let pagesArray: number[] | undefined
if (params.pages && params.pages.trim() !== '') {
try {
@@ -146,12 +115,12 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
if (pagesArray && pagesArray.length === 0) {
pagesArray = undefined
}
} catch (error: any) {
throw new Error(`Page number format error: ${error.message}`)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Page number format error: ${errorMessage}`)
}
}
// Add optional parameters
if (pagesArray && pagesArray.length > 0) {
parameters.pages = pagesArray
}
@@ -173,3 +142,129 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
metadata: { type: 'json', description: 'Processing metadata' },
},
}
export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
...MistralParseBlock,
type: 'mistral_parse_v2',
name: 'Mistral Parser',
description: 'Extract text from PDF documents',
hideFromToolbar: false,
subBlocks: [
{
id: 'fileUpload',
title: 'PDF Document',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'document',
acceptedTypes: 'application/pdf',
placeholder: 'Upload a PDF document',
mode: 'basic',
maxSize: 50,
},
{
id: 'filePath',
title: 'PDF Document',
type: 'short-input' as SubBlockType,
canonicalParamId: 'document',
placeholder: 'Document URL',
mode: 'advanced',
},
{
id: 'resultType',
title: 'Output Format',
type: 'dropdown',
options: [
{ id: 'markdown', label: 'Markdown' },
{ id: 'text', label: 'Plain Text' },
{ id: 'json', label: 'JSON' },
],
},
{
id: 'pages',
title: 'Specific Pages',
type: 'short-input',
placeholder: 'e.g. 0,1,2 (leave empty for all pages)',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input' as SubBlockType,
placeholder: 'Enter your Mistral API key',
password: true,
required: true,
},
],
tools: {
access: ['mistral_parser_v2'],
config: {
tool: createVersionedToolSelector({
baseToolSelector: () => 'mistral_parser',
suffix: '_v2',
fallbackToolId: 'mistral_parser_v2',
}),
params: (params) => {
if (!params || !params.apiKey || params.apiKey.trim() === '') {
throw new Error('Mistral API key is required')
}
const parameters: Record<string, unknown> = {
apiKey: params.apiKey.trim(),
resultType: params.resultType || 'markdown',
}
const documentInput = params.fileUpload || params.filePath || params.document
if (!documentInput) {
throw new Error('PDF document is required')
}
if (typeof documentInput === 'object') {
parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim()
}
let pagesArray: number[] | undefined
if (params.pages && params.pages.trim() !== '') {
try {
pagesArray = params.pages
.split(',')
.map((p: string) => p.trim())
.filter((p: string) => p.length > 0)
.map((p: string) => {
const num = Number.parseInt(p, 10)
if (Number.isNaN(num) || num < 0) {
throw new Error(`Invalid page number: ${p}`)
}
return num
})
if (pagesArray && pagesArray.length === 0) {
pagesArray = undefined
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Page number format error: ${errorMessage}`)
}
}
if (pagesArray && pagesArray.length > 0) {
parameters.pages = pagesArray
}
return parameters
},
},
},
inputs: {
document: { type: 'json', description: 'Document input (file upload or URL reference)' },
filePath: { type: 'string', description: 'PDF document URL (advanced mode)' },
fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' },
apiKey: { type: 'string', description: 'Mistral API key' },
resultType: { type: 'string', description: 'Output format type' },
pages: { type: 'string', description: 'Page selection' },
},
outputs: {
pages: { type: 'array', description: 'Array of page objects from Mistral OCR' },
model: { type: 'string', description: 'Mistral OCR model identifier' },
usage_info: { type: 'json', description: 'Usage statistics from the API' },
document_annotation: { type: 'string', description: 'Structured annotation data' },
},
}

View File

@@ -15,34 +15,22 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
icon: PulseIcon,
subBlocks: [
{
id: 'inputMethod',
title: 'Select Input Method',
type: 'dropdown' as SubBlockType,
options: [
{ id: 'url', label: 'Document URL' },
{ id: 'upload', label: 'Upload Document' },
],
id: 'fileUpload',
title: 'Document',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'document',
acceptedTypes: 'application/pdf,image/*,.docx,.pptx,.xlsx',
placeholder: 'Upload a document',
mode: 'basic',
maxSize: 50,
},
{
id: 'filePath',
title: 'Document URL',
title: 'Document',
type: 'short-input' as SubBlockType,
placeholder: 'Enter full URL to a document (https://example.com/document.pdf)',
condition: {
field: 'inputMethod',
value: 'url',
},
},
{
id: 'fileUpload',
title: 'Upload Document',
type: 'file-upload' as SubBlockType,
acceptedTypes: 'application/pdf,image/*,.docx,.pptx,.xlsx',
condition: {
field: 'inputMethod',
value: 'upload',
},
maxSize: 50,
canonicalParamId: 'document',
placeholder: 'Document URL',
mode: 'advanced',
},
{
id: 'pages',
@@ -84,17 +72,14 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
apiKey: params.apiKey.trim(),
}
const inputMethod = params.inputMethod || 'url'
if (inputMethod === 'url') {
if (!params.filePath || params.filePath.trim() === '') {
throw new Error('Document URL is required')
}
parameters.filePath = params.filePath.trim()
} else if (inputMethod === 'upload') {
if (!params.fileUpload) {
throw new Error('Please upload a document')
}
parameters.fileUpload = params.fileUpload
const documentInput = params.fileUpload || params.filePath || params.document
if (!documentInput) {
throw new Error('Document is required')
}
if (typeof documentInput === 'object') {
parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim()
}
if (params.pages && params.pages.trim() !== '') {
@@ -117,9 +102,9 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
},
},
inputs: {
inputMethod: { type: 'string', description: 'Input method selection' },
filePath: { type: 'string', description: 'Document URL' },
fileUpload: { type: 'json', description: 'Uploaded document file' },
document: { type: 'json', description: 'Document input (file upload or URL reference)' },
filePath: { type: 'string', description: 'Document URL (advanced mode)' },
fileUpload: { type: 'json', description: 'Uploaded document file (basic mode)' },
apiKey: { type: 'string', description: 'Pulse API key' },
pages: { type: 'string', description: 'Page range selection' },
chunking: {

View File

@@ -14,34 +14,22 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
icon: ReductoIcon,
subBlocks: [
{
id: 'inputMethod',
title: 'Select Input Method',
type: 'dropdown' as SubBlockType,
options: [
{ id: 'url', label: 'PDF Document URL' },
{ id: 'upload', label: 'Upload PDF Document' },
],
id: 'fileUpload',
title: 'PDF Document',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'document',
acceptedTypes: 'application/pdf',
placeholder: 'Upload a PDF document',
mode: 'basic',
maxSize: 50,
},
{
id: 'filePath',
title: 'PDF Document URL',
title: 'PDF Document',
type: 'short-input' as SubBlockType,
placeholder: 'Enter full URL to a PDF document (https://example.com/document.pdf)',
condition: {
field: 'inputMethod',
value: 'url',
},
},
{
id: 'fileUpload',
title: 'Upload PDF',
type: 'file-upload' as SubBlockType,
acceptedTypes: 'application/pdf',
condition: {
field: 'inputMethod',
value: 'upload',
},
maxSize: 50,
canonicalParamId: 'document',
placeholder: 'Document URL',
mode: 'advanced',
},
{
id: 'pages',
@@ -80,17 +68,15 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
apiKey: params.apiKey.trim(),
}
const inputMethod = params.inputMethod || 'url'
if (inputMethod === 'url') {
if (!params.filePath || params.filePath.trim() === '') {
throw new Error('PDF Document URL is required')
}
parameters.filePath = params.filePath.trim()
} else if (inputMethod === 'upload') {
if (!params.fileUpload) {
throw new Error('Please upload a PDF document')
}
parameters.fileUpload = params.fileUpload
const documentInput = params.fileUpload || params.filePath || params.document
if (!documentInput) {
throw new Error('PDF document is required')
}
if (typeof documentInput === 'object') {
parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim()
}
let pagesArray: number[] | undefined
@@ -130,9 +116,9 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
},
},
inputs: {
inputMethod: { type: 'string', description: 'Input method selection' },
filePath: { type: 'string', description: 'PDF document URL' },
fileUpload: { type: 'json', description: 'Uploaded PDF file' },
document: { type: 'json', description: 'Document input (file upload or URL reference)' },
filePath: { type: 'string', description: 'PDF document URL (advanced mode)' },
fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' },
apiKey: { type: 'string', description: 'Reducto API key' },
pages: { type: 'string', description: 'Page selection' },
tableOutputFormat: { type: 'string', description: 'Table output format' },

View File

@@ -414,6 +414,10 @@ export const S3Block: BlockConfig<S3Response> = {
},
outputs: {
url: { type: 'string', description: 'URL of S3 object' },
uri: {
type: 'string',
description: 'S3 URI (s3://bucket/key) for use with other AWS services',
},
objects: { type: 'json', description: 'List of objects (for list operation)' },
deleted: { type: 'boolean', description: 'Deletion status' },
metadata: { type: 'json', description: 'Operation metadata' },

View File

@@ -0,0 +1,191 @@
import { TextractIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
import type { TextractParserOutput } from '@/tools/textract/types'
export const TextractBlock: BlockConfig<TextractParserOutput> = {
type: 'textract',
name: 'AWS Textract',
description: 'Extract text, tables, and forms from documents',
authMode: AuthMode.ApiKey,
longDescription: `Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.`,
docsLink: 'https://docs.sim.ai/tools/textract',
category: 'tools',
bgColor: 'linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)',
icon: TextractIcon,
subBlocks: [
{
id: 'processingMode',
title: 'Processing Mode',
type: 'dropdown' as SubBlockType,
options: [
{ id: 'sync', label: 'Single Page (JPEG, PNG, 1-page PDF)' },
{ id: 'async', label: 'Multi-Page (PDF, TIFF via S3)' },
],
tooltip:
'Single Page uses synchronous API for JPEG, PNG, or single-page PDF. Multi-Page uses async API for multi-page PDF/TIFF stored in S3.',
},
{
id: 'fileUpload',
title: 'Document',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'document',
acceptedTypes: 'image/jpeg,image/png,application/pdf',
placeholder: 'Upload JPEG, PNG, or single-page PDF (max 10MB)',
condition: {
field: 'processingMode',
value: 'async',
not: true,
},
mode: 'basic',
maxSize: 10,
},
{
id: 'filePath',
title: 'Document',
type: 'short-input' as SubBlockType,
canonicalParamId: 'document',
placeholder: 'URL to JPEG, PNG, or single-page PDF',
condition: {
field: 'processingMode',
value: 'async',
not: true,
},
mode: 'advanced',
},
{
id: 's3Uri',
title: 'S3 URI',
type: 'short-input' as SubBlockType,
placeholder: 's3://bucket-name/path/to/document.pdf',
condition: {
field: 'processingMode',
value: 'async',
},
},
{
id: 'region',
title: 'AWS Region',
type: 'short-input' as SubBlockType,
placeholder: 'e.g., us-east-1',
required: true,
},
{
id: 'accessKeyId',
title: 'AWS Access Key ID',
type: 'short-input' as SubBlockType,
placeholder: 'Enter your AWS Access Key ID',
password: true,
required: true,
},
{
id: 'secretAccessKey',
title: 'AWS Secret Access Key',
type: 'short-input' as SubBlockType,
placeholder: 'Enter your AWS Secret Access Key',
password: true,
required: true,
},
{
id: 'extractTables',
title: 'Extract Tables',
type: 'switch' as SubBlockType,
},
{
id: 'extractForms',
title: 'Extract Forms (Key-Value Pairs)',
type: 'switch' as SubBlockType,
},
{
id: 'detectSignatures',
title: 'Detect Signatures',
type: 'switch' as SubBlockType,
},
{
id: 'analyzeLayout',
title: 'Analyze Document Layout',
type: 'switch' as SubBlockType,
},
],
tools: {
access: ['textract_parser'],
config: {
tool: () => 'textract_parser',
params: (params) => {
if (!params.accessKeyId || params.accessKeyId.trim() === '') {
throw new Error('AWS Access Key ID is required')
}
if (!params.secretAccessKey || params.secretAccessKey.trim() === '') {
throw new Error('AWS Secret Access Key is required')
}
if (!params.region || params.region.trim() === '') {
throw new Error('AWS Region is required')
}
const processingMode = params.processingMode || 'sync'
const parameters: Record<string, unknown> = {
accessKeyId: params.accessKeyId.trim(),
secretAccessKey: params.secretAccessKey.trim(),
region: params.region.trim(),
processingMode,
}
if (processingMode === 'async') {
if (!params.s3Uri || params.s3Uri.trim() === '') {
throw new Error('S3 URI is required for multi-page processing')
}
parameters.s3Uri = params.s3Uri.trim()
} else {
const documentInput = params.fileUpload || params.filePath || params.document
if (!documentInput) {
throw new Error('Document is required')
}
if (typeof documentInput === 'object') {
parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim()
}
}
const featureTypes: string[] = []
if (params.extractTables) featureTypes.push('TABLES')
if (params.extractForms) featureTypes.push('FORMS')
if (params.detectSignatures) featureTypes.push('SIGNATURES')
if (params.analyzeLayout) featureTypes.push('LAYOUT')
if (featureTypes.length > 0) {
parameters.featureTypes = featureTypes
}
return parameters
},
},
},
inputs: {
processingMode: { type: 'string', description: 'Document type: single-page or multi-page' },
document: { type: 'json', description: 'Document input (file upload or URL reference)' },
filePath: { type: 'string', description: 'Document URL (advanced mode)' },
fileUpload: { type: 'json', description: 'Uploaded document file (basic mode)' },
s3Uri: { type: 'string', description: 'S3 URI for multi-page processing (s3://bucket/key)' },
extractTables: { type: 'boolean', description: 'Extract tables from document' },
extractForms: { type: 'boolean', description: 'Extract form key-value pairs' },
detectSignatures: { type: 'boolean', description: 'Detect signatures' },
analyzeLayout: { type: 'boolean', description: 'Analyze document layout' },
region: { type: 'string', description: 'AWS region' },
accessKeyId: { type: 'string', description: 'AWS Access Key ID' },
secretAccessKey: { type: 'string', description: 'AWS Secret Access Key' },
},
outputs: {
blocks: {
type: 'json',
description: 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)',
},
documentMetadata: {
type: 'json',
description: 'Document metadata containing pages count',
},
modelVersion: {
type: 'string',
description: 'Version of the Textract model used for processing',
},
},
}

View File

@@ -4,8 +4,9 @@ import type { VideoBlockResponse } from '@/tools/video/types'
export const VideoGeneratorBlock: BlockConfig<VideoBlockResponse> = {
type: 'video_generator',
name: 'Video Generator',
name: 'Video Generator (Legacy)',
description: 'Generate videos from text using AI',
hideFromToolbar: true,
authMode: AuthMode.ApiKey,
longDescription:
'Generate high-quality videos from text prompts using leading AI providers. Supports multiple models, aspect ratios, resolutions, and provider-specific features like world consistency, camera controls, and audio generation.',
@@ -427,3 +428,378 @@ export const VideoGeneratorBlock: BlockConfig<VideoBlockResponse> = {
model: { type: 'string', description: 'Model used' },
},
}
export const VideoGeneratorV2Block: BlockConfig<VideoBlockResponse> = {
...VideoGeneratorBlock,
type: 'video_generator_v2',
name: 'Video Generator',
hideFromToolbar: false,
subBlocks: [
{
id: 'provider',
title: 'Provider',
type: 'dropdown',
options: [
{ label: 'Runway Gen-4', id: 'runway' },
{ label: 'Google Veo 3', id: 'veo' },
{ label: 'Luma Dream Machine', id: 'luma' },
{ label: 'MiniMax Hailuo', id: 'minimax' },
{ label: 'Fal.ai (Multi-Model)', id: 'falai' },
],
value: () => 'runway',
required: true,
},
{
id: 'model',
title: 'Model',
type: 'dropdown',
condition: { field: 'provider', value: 'veo' },
options: [
{ label: 'Veo 3', id: 'veo-3' },
{ label: 'Veo 3 Fast', id: 'veo-3-fast' },
{ label: 'Veo 3.1', id: 'veo-3.1' },
],
value: () => 'veo-3',
required: false,
},
{
id: 'model',
title: 'Model',
type: 'dropdown',
condition: { field: 'provider', value: 'luma' },
options: [{ label: 'Ray 2', id: 'ray-2' }],
value: () => 'ray-2',
required: false,
},
{
id: 'model',
title: 'Model',
type: 'dropdown',
condition: { field: 'provider', value: 'minimax' },
options: [{ label: 'Hailuo 2.3', id: 'hailuo-02' }],
value: () => 'hailuo-02',
required: false,
},
{
id: 'endpoint',
title: 'Quality Endpoint',
type: 'dropdown',
condition: { field: 'provider', value: 'minimax' },
options: [
{ label: 'Pro', id: 'pro' },
{ label: 'Standard', id: 'standard' },
],
value: () => 'standard',
required: false,
},
{
id: 'model',
title: 'Model',
type: 'dropdown',
condition: { field: 'provider', value: 'falai' },
options: [
{ label: 'Google Veo 3.1', id: 'veo-3.1' },
{ label: 'OpenAI Sora 2', id: 'sora-2' },
{ label: 'Kling 2.5 Turbo Pro', id: 'kling-2.5-turbo-pro' },
{ label: 'Kling 2.1 Pro', id: 'kling-2.1-pro' },
{ label: 'MiniMax Hailuo 2.3 Pro', id: 'minimax-hailuo-2.3-pro' },
{ label: 'MiniMax Hailuo 2.3 Standard', id: 'minimax-hailuo-2.3-standard' },
{ label: 'WAN 2.1', id: 'wan-2.1' },
{ label: 'LTXV 0.9.8', id: 'ltxv-0.9.8' },
],
value: () => 'veo-3.1',
required: true,
},
{
id: 'prompt',
title: 'Prompt',
type: 'long-input',
placeholder: 'Describe the video you want to generate...',
required: true,
},
{
id: 'duration',
title: 'Duration (seconds)',
type: 'dropdown',
condition: { field: 'provider', value: 'runway' },
options: [
{ label: '5', id: '5' },
{ label: '10', id: '10' },
],
value: () => '5',
required: false,
},
{
id: 'duration',
title: 'Duration (seconds)',
type: 'dropdown',
condition: { field: 'provider', value: 'veo' },
options: [
{ label: '4', id: '4' },
{ label: '6', id: '6' },
{ label: '8', id: '8' },
],
value: () => '8',
required: false,
},
{
id: 'duration',
title: 'Duration (seconds)',
type: 'dropdown',
condition: { field: 'provider', value: 'luma' },
options: [
{ label: '5', id: '5' },
{ label: '9', id: '9' },
],
value: () => '5',
required: false,
},
{
id: 'duration',
title: 'Duration (seconds)',
type: 'dropdown',
condition: { field: 'provider', value: 'minimax' },
options: [
{ label: '6', id: '6' },
{ label: '10', id: '10' },
],
value: () => '6',
required: false,
},
{
id: 'duration',
title: 'Duration (seconds)',
type: 'dropdown',
condition: {
field: 'model',
value: [
'kling-2.5-turbo-pro',
'kling-2.1-pro',
'minimax-hailuo-2.3-pro',
'minimax-hailuo-2.3-standard',
],
},
options: [
{ label: '5', id: '5' },
{ label: '8', id: '8' },
{ label: '10', id: '10' },
],
value: () => '5',
required: false,
},
{
id: 'aspectRatio',
title: 'Aspect Ratio',
type: 'dropdown',
condition: { field: 'provider', value: 'veo' },
options: [
{ label: '16:9', id: '16:9' },
{ label: '9:16', id: '9:16' },
],
value: () => '16:9',
required: false,
},
{
id: 'aspectRatio',
title: 'Aspect Ratio',
type: 'dropdown',
condition: { field: 'provider', value: 'runway' },
options: [
{ label: '16:9', id: '16:9' },
{ label: '9:16', id: '9:16' },
{ label: '1:1', id: '1:1' },
],
value: () => '16:9',
required: false,
},
{
id: 'aspectRatio',
title: 'Aspect Ratio',
type: 'dropdown',
condition: { field: 'provider', value: 'luma' },
options: [
{ label: '16:9', id: '16:9' },
{ label: '9:16', id: '9:16' },
{ label: '1:1', id: '1:1' },
],
value: () => '16:9',
required: false,
},
{
id: 'aspectRatio',
title: 'Aspect Ratio',
type: 'dropdown',
condition: {
field: 'model',
value: [
'kling-2.5-turbo-pro',
'kling-2.1-pro',
'minimax-hailuo-2.3-pro',
'minimax-hailuo-2.3-standard',
],
},
options: [
{ label: '16:9', id: '16:9' },
{ label: '9:16', id: '9:16' },
],
value: () => '16:9',
required: false,
},
{
id: 'resolution',
title: 'Resolution',
type: 'dropdown',
condition: { field: 'provider', value: 'veo' },
options: [
{ label: '720p', id: '720p' },
{ label: '1080p', id: '1080p' },
],
value: () => '1080p',
required: false,
},
{
id: 'resolution',
title: 'Resolution',
type: 'dropdown',
condition: { field: 'provider', value: 'luma' },
options: [
{ label: '540p', id: '540p' },
{ label: '720p', id: '720p' },
{ label: '1080p', id: '1080p' },
],
value: () => '1080p',
required: false,
},
{
id: 'visualReferenceUpload',
title: 'Reference Image',
type: 'file-upload',
canonicalParamId: 'visualReference',
condition: { field: 'provider', value: 'runway' },
placeholder: 'Upload reference image',
mode: 'basic',
multiple: false,
required: true,
acceptedTypes: '.jpg,.jpeg,.png,.webp',
},
{
id: 'visualReferenceInput',
title: 'Reference Image',
type: 'short-input',
canonicalParamId: 'visualReference',
condition: { field: 'provider', value: 'runway' },
placeholder: 'Reference image from previous blocks',
mode: 'advanced',
},
{
id: 'cameraControl',
title: 'Camera Controls',
type: 'long-input',
condition: { field: 'provider', value: 'luma' },
placeholder: 'JSON: [{ "key": "pan_right" }, { "key": "zoom_in" }]',
required: false,
},
{
id: 'promptOptimizer',
title: 'Prompt Optimizer',
type: 'switch',
condition: { field: 'provider', value: 'minimax' },
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your provider API key',
password: true,
required: true,
},
],
tools: {
access: ['video_runway', 'video_veo', 'video_luma', 'video_minimax', 'video_falai'],
config: {
tool: (params) => {
switch (params.provider) {
case 'runway':
return 'video_runway'
case 'veo':
return 'video_veo'
case 'luma':
return 'video_luma'
case 'minimax':
return 'video_minimax'
case 'falai':
return 'video_falai'
default:
return 'video_runway'
}
},
params: (params) => {
const visualRef =
params.visualReferenceUpload || params.visualReferenceInput || params.visualReference
return {
provider: params.provider,
apiKey: params.apiKey,
model: params.model,
endpoint: params.endpoint,
prompt: params.prompt,
duration: params.duration ? Number(params.duration) : undefined,
aspectRatio: params.aspectRatio,
resolution: params.resolution,
visualReference: visualRef,
consistencyMode: params.consistencyMode,
stylePreset: params.stylePreset,
promptOptimizer: params.promptOptimizer,
cameraControl: params.cameraControl
? typeof params.cameraControl === 'string'
? JSON.parse(params.cameraControl)
: params.cameraControl
: undefined,
}
},
},
},
inputs: {
provider: {
type: 'string',
description: 'Video generation provider (runway, veo, luma, minimax)',
},
apiKey: { type: 'string', description: 'Provider API key' },
model: {
type: 'string',
description: 'Provider-specific model',
},
endpoint: {
type: 'string',
description: 'Quality endpoint for MiniMax (pro, standard)',
},
prompt: { type: 'string', description: 'Text prompt for video generation' },
duration: { type: 'number', description: 'Video duration in seconds' },
aspectRatio: {
type: 'string',
description: 'Aspect ratio (16:9, 9:16, 1:1) - not available for MiniMax',
},
resolution: {
type: 'string',
description: 'Video resolution - not available for MiniMax (fixed per endpoint)',
},
visualReference: { type: 'json', description: 'Reference image for Runway (UserFile)' },
visualReferenceUpload: { type: 'json', description: 'Uploaded reference image (basic mode)' },
visualReferenceInput: {
type: 'json',
description: 'Reference image from previous blocks (advanced mode)',
},
consistencyMode: {
type: 'string',
description: 'Consistency mode for Runway (character, object, style, location)',
},
stylePreset: { type: 'string', description: 'Style preset for Runway' },
promptOptimizer: {
type: 'boolean',
description: 'Enable prompt optimization for MiniMax (default: true)',
},
cameraControl: {
type: 'json',
description: 'Camera controls for Luma (pan, zoom, tilt, truck, tracking)',
},
},
}

View File

@@ -14,7 +14,7 @@ import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger'
import { CirclebackBlock } from '@/blocks/blocks/circleback'
import { ClayBlock } from '@/blocks/blocks/clay'
import { ConditionBlock } from '@/blocks/blocks/condition'
import { ConfluenceBlock } from '@/blocks/blocks/confluence'
import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence'
import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor'
import { DatadogBlock } from '@/blocks/blocks/datadog'
import { DiscordBlock } from '@/blocks/blocks/discord'
@@ -25,7 +25,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { ExaBlock } from '@/blocks/blocks/exa'
import { FileBlock } from '@/blocks/blocks/file'
import { FileBlock, FileV2Block } from '@/blocks/blocks/file'
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
import { FirefliesBlock } from '@/blocks/blocks/fireflies'
import { FunctionBlock } from '@/blocks/blocks/function'
@@ -74,7 +74,7 @@ import { MemoryBlock } from '@/blocks/blocks/memory'
import { MicrosoftExcelBlock, MicrosoftExcelV2Block } from '@/blocks/blocks/microsoft_excel'
import { MicrosoftPlannerBlock } from '@/blocks/blocks/microsoft_planner'
import { MicrosoftTeamsBlock } from '@/blocks/blocks/microsoft_teams'
import { MistralParseBlock } from '@/blocks/blocks/mistral_parse'
import { MistralParseBlock, MistralParseV2Block } from '@/blocks/blocks/mistral_parse'
import { MongoDBBlock } from '@/blocks/blocks/mongodb'
import { MySQLBlock } from '@/blocks/blocks/mysql'
import { Neo4jBlock } from '@/blocks/blocks/neo4j'
@@ -123,6 +123,7 @@ import { SttBlock } from '@/blocks/blocks/stt'
import { SupabaseBlock } from '@/blocks/blocks/supabase'
import { TavilyBlock } from '@/blocks/blocks/tavily'
import { TelegramBlock } from '@/blocks/blocks/telegram'
import { TextractBlock } from '@/blocks/blocks/textract'
import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TinybirdBlock } from '@/blocks/blocks/tinybird'
import { TranslateBlock } from '@/blocks/blocks/translate'
@@ -132,7 +133,7 @@ import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
import { TypeformBlock } from '@/blocks/blocks/typeform'
import { VariablesBlock } from '@/blocks/blocks/variables'
import { VideoGeneratorBlock } from '@/blocks/blocks/video_generator'
import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator'
import { VisionBlock } from '@/blocks/blocks/vision'
import { WaitBlock } from '@/blocks/blocks/wait'
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
@@ -169,6 +170,7 @@ export const registry: Record<string, BlockConfig> = {
clay: ClayBlock,
condition: ConditionBlock,
confluence: ConfluenceBlock,
confluence_v2: ConfluenceV2Block,
cursor: CursorBlock,
cursor_v2: CursorV2Block,
datadog: DatadogBlock,
@@ -181,6 +183,7 @@ export const registry: Record<string, BlockConfig> = {
evaluator: EvaluatorBlock,
exa: ExaBlock,
file: FileBlock,
file_v2: FileV2Block,
firecrawl: FirecrawlBlock,
fireflies: FirefliesBlock,
function: FunctionBlock,
@@ -236,6 +239,7 @@ export const registry: Record<string, BlockConfig> = {
microsoft_planner: MicrosoftPlannerBlock,
microsoft_teams: MicrosoftTeamsBlock,
mistral_parse: MistralParseBlock,
mistral_parse_v2: MistralParseV2Block,
mongodb: MongoDBBlock,
mysql: MySQLBlock,
neo4j: Neo4jBlock,
@@ -286,6 +290,7 @@ export const registry: Record<string, BlockConfig> = {
supabase: SupabaseBlock,
tavily: TavilyBlock,
telegram: TelegramBlock,
textract: TextractBlock,
thinking: ThinkingBlock,
tinybird: TinybirdBlock,
translate: TranslateBlock,
@@ -296,6 +301,7 @@ export const registry: Record<string, BlockConfig> = {
typeform: TypeformBlock,
variables: VariablesBlock,
video_generator: VideoGeneratorBlock,
video_generator_v2: VideoGeneratorV2Block,
vision: VisionBlock,
wait: WaitBlock,
wealthbox: WealthboxBlock,

View File

@@ -25,7 +25,7 @@ const badgeVariants = cva(
orange: `${STATUS_BASE} bg-[#fed7aa] text-[#c2410c] dark:bg-[rgba(249,115,22,0.2)] dark:text-[#fdba74]`,
amber: `${STATUS_BASE} bg-[#fde68a] text-[#a16207] dark:bg-[rgba(245,158,11,0.2)] dark:text-[#fcd34d]`,
teal: `${STATUS_BASE} bg-[#99f6e4] text-[#0f766e] dark:bg-[rgba(20,184,166,0.2)] dark:text-[#5eead4]`,
cyan: `${STATUS_BASE} bg-[#a5f3fc] text-[#0e7490] dark:bg-[rgba(14,165,233,0.2)] dark:text-[#7dd3fc]`,
cyan: `${STATUS_BASE} bg-[var(--surface-4)] text-[#0891b2] dark:bg-[rgba(14,165,233,0.2)] dark:text-[#7dd3fc]`,
'gray-secondary': `${STATUS_BASE} bg-[var(--surface-4)] text-[var(--text-secondary)]`,
},
size: {

View File

@@ -4093,6 +4093,23 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='10 14 60 52'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<path
d='M22.0624102,50 C24.3763895,53.603 28.4103535,56 33.0003125,56 C40.1672485,56 45.9991964,50.168 45.9991964,43 C45.9991964,35.832 40.1672485,30 33.0003125,30 C27.6033607,30 22.9664021,33.307 21.0024196,38 L23.2143999,38 C25.0393836,34.444 28.7363506,32 33.0003125,32 C39.0652583,32 43.9992143,36.935 43.9992143,43 C43.9992143,49.065 39.0652583,54 33.0003125,54 C29.5913429,54 26.5413702,52.441 24.5213882,50 L22.0624102,50 Z M37.0002768,45 L37.0002768,43 L41.9992321,43 C41.9992321,38.038 37.9622682,34 33.0003125,34 C28.0373568,34 23.9993929,38.038 23.9993929,43 L28.9993482,43 L28.9993482,45 L24.2313908,45 C25.1443826,49.002 28.7253507,52 33.0003125,52 C35.1362934,52 37.0992759,51.249 38.6442621,50 L34.0003036,50 L34.0003036,48 L40.4782457,48 C41.0812403,47.102 41.5202364,46.087 41.7682342,45 L37.0002768,45 Z M21.0024196,48 L23.2143999,48 C22.4434068,46.498 22.0004107,44.801 22.0004107,43 C22.0004107,41.959 22.1554093,40.955 22.4264069,40 L20.3634253,40 C20.1344274,40.965 19.9994286,41.966 19.9994286,43 C19.9994286,44.771 20.3584254,46.46 21.0024196,48 L21.0024196,48 Z M19.7434309,50 L17.0004554,50 L17.0004554,48 L18.8744386,48 C18.5344417,47.04 18.2894438,46.038 18.1494451,45 L15.4144695,45 L16.707458,46.293 L15.2924706,47.707 L12.2924974,44.707 C11.9025009,44.316 11.9025009,43.684 12.2924974,43.293 L15.2924706,40.293 L16.707458,41.707 L15.4144695,43 L18.0004464,43 C18.0004464,41.973 18.1044455,40.97 18.3024437,40 L17.0004554,40 L17.0004554,38 L18.8744386,38 C20.9404202,32.184 26.4833707,28 33.0003125,28 C37.427273,28 41.4002375,29.939 44.148213,33 L59.0000804,33 L59.0000804,35 L45.6661994,35 C47.1351863,37.318 47.9991786,40.058 47.9991786,43 L59.0000804,43 L59.0000804,45 L47.8501799,45 C46.8681887,52.327 40.5912447,58 33.0003125,58 C27.2563638,58 22.2624084,54.752 19.7434309,50 L19.7434309,50 Z M37.0002768,39 C37.0002768,38.448 36.5522808,38 36.0002857,38 L29.9993482,38 C29.4473442,38 28.9993482,38.448 28.9993482,39 L28.9993482,41 L31.0003304,41 L31.0003304,40 L32.0003214,40 L32.0003214,43 L31.0003304,43 L31.0003304,45 L35.0002946,45 L35.0002946,43 L34.0003036,43 L34.0003036,40 L35.0002946,40 L35.0002946,41 L37.0002768,41 L37.0002768,39 Z M49.0001696,40 L59.0000804,40 L59.0000804,38 L49.0001696,38 L49.0001696,40 Z M49.0001696,50 L59.0000804,50 L59.0000804,48 L49.0001696,48 L49.0001696,50 Z M57.0000982,27 L60.5850662,27 L57.0000982,23.414 L57.0000982,27 Z M63.7070383,27.293 C63.8940367,27.48 64.0000357,27.735 64.0000357,28 L64.0000357,63 C64.0000357,63.552 63.5520397,64 63.0000446,64 L32.0003304,64 C31.4473264,64 31.0003304,63.552 31.0003304,63 L31.0003304,59 L33.0003125,59 L33.0003125,62 L62.0000536,62 L62.0000536,29 L56.0001071,29 C55.4471121,29 55.0001161,28.552 55.0001161,28 L55.0001161,22 L33.0003125,22 L33.0003125,27 L31.0003304,27 L31.0003304,21 C31.0003304,20.448 31.4473264,20 32.0003304,20 L56.0001071,20 C56.2651048,20 56.5191025,20.105 56.7071008,20.293 L63.7070383,27.293 Z M68,24.166 L68,61 C68,61.552 67.552004,62 67.0000089,62 L65.0000268,62 L65.0000268,60 L66.0000179,60 L66.0000179,24.612 L58.6170838,18 L36.0002857,18 L36.0002857,19 L34.0003036,19 L34.0003036,17 C34.0003036,16.448 34.4472996,16 35.0003036,16 L59.0000804,16 C59.2460782,16 59.483076,16.091 59.6660744,16.255 L67.666003,23.42 C67.8780011,23.61 68,23.881 68,24.166 L68,24.166 Z'
fill='currentColor'
/>
</svg>
)
}
export function McpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What's New at Sim</title>
</head>
<body style="margin:0;padding:0;background-color:#ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" style="background-color:#ffffff;">
<tr>
<td align="center" style="padding:0 16px;">
<table width="600" cellspacing="0" cellpadding="0" border="0" role="presentation" style="max-width:600px;width:100%;">
<!-- Logo -->
<tr>
<td align="center" style="padding-top:32px;padding-bottom:16px;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/logo.png" width="79" alt="Sim Logo" style="display:block;width:79px;" border="0">
</a>
</td>
</tr>
<!-- Intro Paragraph -->
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:20px;padding-top:8px;text-align:left;">
<p style="margin:0;">We're excited to introduce <strong>Sim v0.5</strong>, the next evolution of our agent workflow platform—built for teams who need <strong>context-aware AI assistance</strong>, <strong>seamless tool deployment</strong>, and <strong>full execution observability</strong> in production.</p>
</td>
</tr>
<!-- CTA Button -->
<tr>
<td align="center" style="padding-bottom:25px;padding-top:5px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="background-color:#32bd7e;border-radius:5px;">
<a href="https://sim.ai" style="display:inline-block;font-weight:500;font-size:14px;padding:7px 16px;text-decoration:none;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;" target="_blank">Try Sim</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Header Text -->
<tr>
<td align="left" valign="top" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;word-break:break-word;padding-top:10px;padding-bottom:8px;text-align:left;">
<h2 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:28px;font-weight:600;word-break:break-word;">Get things done <em>faster</em></h2>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:30px;text-align:left;">
<p style="margin:0;">You're focused on the most critical tasks, let agents handle the rest.</p>
</td>
</tr>
<!-- FEATURE 1: Copilot -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:10px;padding-bottom:12px;text-align:left;">
<strong>Copilot</strong>
</td>
</tr>
<tr>
<td align="center" style="border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/copilot.jpg" width="570" alt="Sim Copilot" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Copilot is a context-aware assistant embedded directly into the Sim editor. It has first-class access to your workflows, blocks, execution logs, and documentation—helping you build, debug, and optimize without leaving the canvas. Ask it to explain a workflow, propose changes, or execute actions. All writes are gated behind explicit approval, so you stay in control.</p>
</td>
</tr>
<!-- FEATURE 2: MCP Deployment -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:20px;padding-bottom:12px;text-align:left;">
<strong>MCP Deployment</strong>
</td>
</tr>
<tr>
<td align="center">
<a href="https://sim.ai" style="text-decoration:none;display:block;" target="_blank">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" style="background-color:#181C1E;border-radius:8px;">
<tr>
<td align="center" style="padding:40px 30px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="padding-bottom:16px;">
<img src="https://sim.ai/email/broadcast/v0.5/mcp.png" width="48" height="48" alt="MCP" style="display:block;" border="0">
</td>
</tr>
<tr>
<td align="center">
<p style="margin:0;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:20px;font-weight:500;">Expose workflows as tools for any agent</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Deploy any workflow as an MCP tool and expose it to any MCP-compatible agent—Claude Desktop, Cursor, or your own applications. Define custom tool names, descriptions, and parameter schemas. Your workflows become reusable building blocks that other agents can invoke, turning Sim into the backend for your agentic systems.</p>
</td>
</tr>
<!-- FEATURE 3: Logs & Dashboard -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:20px;padding-bottom:12px;text-align:left;">
<strong>Logs & Dashboard</strong>
</td>
</tr>
<tr>
<td align="center" style="border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/dashboard.jpg" width="570" alt="Logs & Dashboard" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Full observability for every execution. Trace spans show you exactly what happened at each step—durations, token usage, cost breakdowns by model, and error details when things go wrong. Filter by time range, trigger type, or workflow. Live mode auto-refreshes every 5 seconds. Restore any previous workflow state with execution snapshots.</p>
</td>
</tr>
<!-- FEATURE 4: Collaboration -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:20px;padding-bottom:12px;text-align:left;">
<strong>Realtime Collaboration</strong>
</td>
</tr>
<tr>
<td align="center" style="border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/collaboration.jpg" width="570" alt="Realtime Collaboration" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Invite teammates to your workspace and build workflows together in realtime—like Figma for AI agents. See cursors move, blocks added, and configurations updated as they happen. The operation queue ensures reliability even under poor network conditions. Undo/redo works per-user, so your changes stay separate from your collaborators'.</p>
</td>
</tr>
<!-- Divider -->
<tr>
<td align="center" style="padding-bottom:10px;padding-top:10px;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" height="1" style="border-top:1px solid #ededed;">
<tr>
<td height="0" style="font-size:0;line-height:0;">&#173;</td>
</tr>
</table>
</td>
</tr>
<!-- Final CTA -->
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:15px;padding-top:15px;text-align:left;">
<p style="margin:0;">Ready to build? These features are available now in Sim.</p>
</td>
</tr>
<tr>
<td align="center" style="padding-bottom:30px;padding-top:15px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="background-color:#32bd7e;border-radius:5px;">
<a href="https://sim.ai" style="display:inline-block;font-weight:500;font-size:14px;padding:7px 16px;text-decoration:none;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;" target="_blank">Get Started</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Divider -->
<tr>
<td align="center" style="padding-top:20px;padding-bottom:20px;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" height="1" style="border-top:1px solid #ededed;">
<tr>
<td height="0" style="font-size:0;line-height:0;">&#173;</td>
</tr>
</table>
</td>
</tr>
<!-- Social links row -->
<tr>
<td align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tbody>
<tr>
<td align="center" style="padding:0 8px 0 0;">
<a href="https://sim.ai/x" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/x-icon.png" width="20" height="20" alt="X" style="display:block;" border="0">
</a>
</td>
<td align="center" style="padding:0 8px;">
<a href="https://sim.ai/discord" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/discord-icon.png" width="20" height="20" alt="Discord" style="display:block;" border="0">
</a>
</td>
<td align="center" style="padding:0 8px;">
<a href="https://sim.ai/github" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/github-icon.png" width="20" height="20" alt="GitHub" style="display:block;" border="0">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="16" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Address row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
Sim, 80 Langton St, San Francisco, CA 94103, USA
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="8" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Contact row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
Questions? <a href="mailto:support@sim.ai" style="color:#737373;text-decoration:underline;font-weight:normal;">support@sim.ai</a>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="8" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Links row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
<a href="https://sim.ai/privacy" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Privacy Policy</a>
&nbsp;&nbsp;
<a href="https://sim.ai/terms" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Terms of Service</a>
&nbsp;&nbsp;
<a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Unsubscribe</a>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="16" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Copyright row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
© 2026 Sim, All Rights Reserved
</td>
</tr>
<!-- Bottom spacer -->
<tr>
<td height="32" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -3,6 +3,10 @@ import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
containsUserFileWithMetadata,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import {
BlockType,
buildResumeApiUrl,
@@ -135,6 +139,14 @@ export class BlockExecutor {
normalizedOutput = this.normalizeOutput(output)
}
if (ctx.includeFileBase64 && containsUserFileWithMetadata(normalizedOutput)) {
normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, {
requestId: ctx.metadata.requestId,
executionId: ctx.executionId,
maxBytes: ctx.base64MaxBytes,
})) as NormalizedBlockOutput
}
const duration = Date.now() - startTime
if (blockLog) {

View File

@@ -169,6 +169,8 @@ export class DAGExecutor {
onBlockStart: this.contextExtensions.onBlockStart,
onBlockComplete: this.contextExtensions.onBlockComplete,
abortSignal: this.contextExtensions.abortSignal,
includeFileBase64: this.contextExtensions.includeFileBase64,
base64MaxBytes: this.contextExtensions.base64MaxBytes,
}
if (this.contextExtensions.resumeFromSnapshot) {

View File

@@ -89,6 +89,8 @@ export interface ContextExtensions {
* When aborted, the execution should stop gracefully.
*/
abortSignal?: AbortSignal
includeFileBase64?: boolean
base64MaxBytes?: number
onStream?: (streamingExecution: unknown) => Promise<void>
onBlockStart?: (
blockId: string,

View File

@@ -387,7 +387,6 @@ describe('AgentBlockHandler', () => {
code: 'return { result: "auto tool executed", input }',
input: 'test input',
}),
false, // skipProxy
false, // skipPostProcess
expect.any(Object) // execution context
)
@@ -400,7 +399,6 @@ describe('AgentBlockHandler', () => {
code: 'return { result: "force tool executed", input }',
input: 'another test',
}),
false, // skipProxy
false, // skipPostProcess
expect.any(Object) // execution context
)
@@ -1407,7 +1405,7 @@ describe('AgentBlockHandler', () => {
})
it('should handle MCP tools in agent execution', async () => {
mockExecuteTool.mockImplementation((toolId, params, skipProxy, skipPostProcess, context) => {
mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => {
if (isMcpTool(toolId)) {
return Promise.resolve({
success: true,
@@ -1682,7 +1680,7 @@ describe('AgentBlockHandler', () => {
it('should provide workspaceId context for MCP tool execution', async () => {
let capturedContext: any
mockExecuteTool.mockImplementation((toolId, params, skipProxy, skipPostProcess, context) => {
mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => {
capturedContext = context
if (isMcpTool(toolId)) {
return Promise.resolve({

View File

@@ -325,7 +325,6 @@ export class AgentBlockHandler implements BlockHandler {
},
},
false,
false,
ctx
)

View File

@@ -106,7 +106,6 @@ describe('ApiBlockHandler', () => {
body: { key: 'value' }, // Expect parsed body
_context: { workflowId: 'test-workflow-id' },
},
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)
@@ -158,7 +157,6 @@ describe('ApiBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: expectedParsedBody }),
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)
@@ -175,7 +173,6 @@ describe('ApiBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: 'This is plain text' }),
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)
@@ -192,7 +189,6 @@ describe('ApiBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: undefined }),
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)

View File

@@ -82,7 +82,6 @@ export class ApiBlockHandler implements BlockHandler {
},
},
false,
false,
ctx
)

View File

@@ -201,7 +201,6 @@ describe('ConditionBlockHandler', () => {
},
}),
false,
false,
mockContext
)
})

View File

@@ -44,7 +44,6 @@ export async function evaluateConditionExpression(
},
},
false,
false,
ctx
)

View File

@@ -84,7 +84,6 @@ describe('FunctionBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expectedToolParams,
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)
@@ -117,7 +116,6 @@ describe('FunctionBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expectedToolParams,
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)
@@ -142,7 +140,6 @@ describe('FunctionBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expectedToolParams,
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)

View File

@@ -42,7 +42,6 @@ export class FunctionBlockHandler implements BlockHandler {
},
},
false,
false,
ctx
)

View File

@@ -95,7 +95,6 @@ describe('GenericBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'some_custom_tool',
expectedToolParams,
false, // skipProxy
false, // skipPostProcess
mockContext // execution context
)

View File

@@ -70,7 +70,6 @@ export class GenericBlockHandler implements BlockHandler {
},
},
false,
false,
ctx
)

View File

@@ -633,7 +633,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
blockNameMapping: blockNameMappingWithPause,
}
const result = await executeTool(toolId, toolParams, false, false, ctx)
const result = await executeTool(toolId, toolParams, false, ctx)
const durationMs = Date.now() - startTime
if (!result.success) {

View File

@@ -11,6 +11,7 @@ export interface UserFile {
type: string
key: string
context?: string
base64?: string
}
export interface ParallelPauseScope {
@@ -236,6 +237,19 @@ export interface ExecutionContext {
// Dynamically added nodes that need to be scheduled (e.g., from parallel expansion)
pendingDynamicNodes?: string[]
/**
* When true, UserFile objects in block outputs will be hydrated with base64 content
* before being stored in execution state. This ensures base64 is available for
* variable resolution in downstream blocks.
*/
includeFileBase64?: boolean
/**
* Maximum file size in bytes for base64 hydration. Files larger than this limit
* will not have their base64 content fetched.
*/
base64MaxBytes?: number
}
export interface ExecutionResult {

View File

@@ -1,4 +1,4 @@
import { isUserFile } from '@/lib/core/utils/display-filters'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
classifyStartBlockType,
getLegacyStarterMode,
@@ -234,7 +234,7 @@ function getFilesFromWorkflowInput(workflowInput: unknown): UserFile[] | undefin
return undefined
}
const files = workflowInput.files
if (Array.isArray(files) && files.every(isUserFile)) {
if (Array.isArray(files) && files.every(isUserFileWithMetadata)) {
return files
}
return undefined

View File

@@ -1,3 +1,4 @@
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
import {
isReference,
normalizeName,
@@ -20,11 +21,58 @@ function isPathInOutputSchema(
return true
}
const isFileArrayType = (value: any): boolean =>
value?.type === 'file[]' || value?.type === 'files'
let current: any = outputs
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i]
const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/)
if (arrayMatch) {
const [, prop] = arrayMatch
let fieldDef: any
if (prop in current) {
fieldDef = current[prop]
} else if (current.properties && prop in current.properties) {
fieldDef = current.properties[prop]
} else if (current.type === 'array' && current.items) {
if (current.items.properties && prop in current.items.properties) {
fieldDef = current.items.properties[prop]
} else if (prop in current.items) {
fieldDef = current.items[prop]
}
}
if (!fieldDef) {
return false
}
if (isFileArrayType(fieldDef)) {
if (i + 1 < pathParts.length) {
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(pathParts[i + 1] as any)
}
return true
}
if (fieldDef.type === 'array' && fieldDef.items) {
current = fieldDef.items
continue
}
current = fieldDef
continue
}
if (/^\d+$/.test(part)) {
if (isFileArrayType(current)) {
if (i + 1 < pathParts.length) {
const nextPart = pathParts[i + 1]
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(nextPart as any)
}
return true
}
continue
}
@@ -33,7 +81,15 @@ function isPathInOutputSchema(
}
if (part in current) {
current = current[part]
const nextCurrent = current[part]
if (nextCurrent?.type === 'file[]' && i + 1 < pathParts.length) {
const nextPart = pathParts[i + 1]
if (/^\d+$/.test(nextPart) && i + 2 < pathParts.length) {
const propertyPart = pathParts[i + 2]
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(propertyPart as any)
}
}
current = nextCurrent
continue
}
@@ -53,6 +109,10 @@ function isPathInOutputSchema(
}
}
if (isFileArrayType(current) && USER_FILE_ACCESSIBLE_PROPERTIES.includes(part as any)) {
return true
}
if ('type' in current && typeof current.type === 'string') {
if (!current.properties && !current.items) {
return false

View File

@@ -98,8 +98,6 @@ export interface ApiResponse {
*/
export interface StreamingResponse extends ApiResponse {
stream?: ReadableStream
streamId?: string
chatId?: string
}
/**
@@ -165,15 +163,9 @@ export async function sendStreamingMessage(
}
}
// Extract stream and chat IDs from headers for resumption support
const streamId = response.headers.get('X-Stream-Id') || undefined
const chatId = response.headers.get('X-Chat-Id') || undefined
return {
success: true,
stream: response.body,
streamId,
chatId,
}
} catch (error) {
// Handle AbortError gracefully - this is expected when user aborts

View File

@@ -1,324 +0,0 @@
/**
* Render events are the normalized event types sent to clients.
* These are independent of the sim agent's internal event format.
*/
export type RenderEventType =
| 'text_delta'
| 'text_complete'
| 'tool_pending'
| 'tool_executing'
| 'tool_success'
| 'tool_error'
| 'tool_result'
| 'subagent_start'
| 'subagent_text'
| 'subagent_tool_call'
| 'subagent_end'
| 'thinking_start'
| 'thinking_delta'
| 'thinking_end'
| 'message_start'
| 'message_complete'
| 'chat_id'
| 'conversation_id'
| 'error'
| 'stream_status'
export interface BaseRenderEvent {
type: RenderEventType
timestamp?: number
}
export interface TextDeltaEvent extends BaseRenderEvent {
type: 'text_delta'
content: string
}
export interface TextCompleteEvent extends BaseRenderEvent {
type: 'text_complete'
content: string
}
export interface ToolPendingEvent extends BaseRenderEvent {
type: 'tool_pending'
toolCallId: string
toolName: string
args?: Record<string, unknown>
display?: {
label: string
icon?: string
}
}
export interface ToolExecutingEvent extends BaseRenderEvent {
type: 'tool_executing'
toolCallId: string
toolName: string
}
export interface ToolSuccessEvent extends BaseRenderEvent {
type: 'tool_success'
toolCallId: string
toolName: string
result?: unknown
display?: {
label: string
icon?: string
}
}
export interface ToolErrorEvent extends BaseRenderEvent {
type: 'tool_error'
toolCallId: string
toolName: string
error: string
display?: {
label: string
icon?: string
}
}
export interface ToolResultEvent extends BaseRenderEvent {
type: 'tool_result'
toolCallId: string
success: boolean
result?: unknown
error?: string
failedDependency?: boolean
skipped?: boolean
}
export interface SubagentStartEvent extends BaseRenderEvent {
type: 'subagent_start'
parentToolCallId: string
subagentName: string
}
export interface SubagentTextEvent extends BaseRenderEvent {
type: 'subagent_text'
parentToolCallId: string
content: string
}
export interface SubagentToolCallEvent extends BaseRenderEvent {
type: 'subagent_tool_call'
parentToolCallId: string
toolCallId: string
toolName: string
args?: Record<string, unknown>
state: 'pending' | 'executing' | 'success' | 'error'
result?: unknown
error?: string
}
export interface SubagentEndEvent extends BaseRenderEvent {
type: 'subagent_end'
parentToolCallId: string
}
export interface ThinkingStartEvent extends BaseRenderEvent {
type: 'thinking_start'
}
export interface ThinkingDeltaEvent extends BaseRenderEvent {
type: 'thinking_delta'
content: string
}
export interface ThinkingEndEvent extends BaseRenderEvent {
type: 'thinking_end'
}
export interface MessageStartEvent extends BaseRenderEvent {
type: 'message_start'
messageId: string
}
export interface MessageCompleteEvent extends BaseRenderEvent {
type: 'message_complete'
messageId: string
content?: string
}
export interface ChatIdEvent extends BaseRenderEvent {
type: 'chat_id'
chatId: string
}
export interface ConversationIdEvent extends BaseRenderEvent {
type: 'conversation_id'
conversationId: string
}
export interface ErrorEvent extends BaseRenderEvent {
type: 'error'
error: string
code?: string
}
export interface StreamStatusEvent extends BaseRenderEvent {
type: 'stream_status'
status: 'streaming' | 'complete' | 'error' | 'aborted'
error?: string
}
export type RenderEvent =
| TextDeltaEvent
| TextCompleteEvent
| ToolPendingEvent
| ToolExecutingEvent
| ToolSuccessEvent
| ToolErrorEvent
| ToolResultEvent
| SubagentStartEvent
| SubagentTextEvent
| SubagentToolCallEvent
| SubagentEndEvent
| ThinkingStartEvent
| ThinkingDeltaEvent
| ThinkingEndEvent
| MessageStartEvent
| MessageCompleteEvent
| ChatIdEvent
| ConversationIdEvent
| ErrorEvent
| StreamStatusEvent
/**
* Serialize a render event to SSE format
*/
export function serializeRenderEvent(event: RenderEvent): string {
const eventWithTimestamp = {
...event,
timestamp: event.timestamp || Date.now(),
}
return `event: ${event.type}\ndata: ${JSON.stringify(eventWithTimestamp)}\n\n`
}
/**
* Parse an SSE chunk into a render event
*/
export function parseRenderEvent(chunk: string): RenderEvent | null {
// SSE format: "event: <type>\ndata: <json>\n\n"
const lines = chunk.trim().split('\n')
let eventType: string | null = null
let data: string | null = null
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7)
} else if (line.startsWith('data: ')) {
data = line.slice(6)
}
}
if (!data) return null
try {
const parsed = JSON.parse(data)
// If we extracted an event type from SSE, use it; otherwise use from data
if (eventType && !parsed.type) {
parsed.type = eventType
}
return parsed as RenderEvent
} catch {
return null
}
}
/**
* Create a text delta event
*/
export function createTextDelta(content: string): TextDeltaEvent {
return { type: 'text_delta', content, timestamp: Date.now() }
}
/**
* Create a tool pending event
*/
export function createToolPending(
toolCallId: string,
toolName: string,
args?: Record<string, unknown>,
display?: { label: string; icon?: string }
): ToolPendingEvent {
return {
type: 'tool_pending',
toolCallId,
toolName,
args,
display,
timestamp: Date.now(),
}
}
/**
* Create a tool executing event
*/
export function createToolExecuting(toolCallId: string, toolName: string): ToolExecutingEvent {
return { type: 'tool_executing', toolCallId, toolName, timestamp: Date.now() }
}
/**
* Create a tool success event
*/
export function createToolSuccess(
toolCallId: string,
toolName: string,
result?: unknown,
display?: { label: string; icon?: string }
): ToolSuccessEvent {
return {
type: 'tool_success',
toolCallId,
toolName,
result,
display,
timestamp: Date.now(),
}
}
/**
* Create a tool error event
*/
export function createToolError(
toolCallId: string,
toolName: string,
error: string,
display?: { label: string; icon?: string }
): ToolErrorEvent {
return {
type: 'tool_error',
toolCallId,
toolName,
error,
display,
timestamp: Date.now(),
}
}
/**
* Create a message complete event
*/
export function createMessageComplete(messageId: string, content?: string): MessageCompleteEvent {
return { type: 'message_complete', messageId, content, timestamp: Date.now() }
}
/**
* Create a stream status event
*/
export function createStreamStatus(
status: 'streaming' | 'complete' | 'error' | 'aborted',
error?: string
): StreamStatusEvent {
return { type: 'stream_status', status, error, timestamp: Date.now() }
}
/**
* Create an error event
*/
export function createError(error: string, code?: string): ErrorEvent {
return { type: 'error', error, code, timestamp: Date.now() }
}

View File

@@ -1,299 +0,0 @@
'use client'
import { createLogger } from '@sim/logger'
const logger = createLogger('StreamClient')
export interface StreamMetadata {
streamId: string
chatId: string
userId: string
workflowId: string
userMessageId: string
assistantMessageId?: string
status: 'pending' | 'streaming' | 'complete' | 'error' | 'aborted'
isClientSession: boolean
createdAt: number
updatedAt: number
completedAt?: number
error?: string
}
export interface StreamResumeResponse {
metadata: StreamMetadata
events: string[]
toolCalls: Record<string, unknown>
totalEvents: number
nextOffset: number
}
const STREAM_ID_STORAGE_KEY = 'copilot:activeStream'
const RECONNECT_DELAY_MS = 1000
const MAX_RECONNECT_ATTEMPTS = 5
/**
* Store active stream info for potential resumption
*/
export function storeActiveStream(
chatId: string,
streamId: string,
messageId: string
): void {
try {
const data = { chatId, streamId, messageId, storedAt: Date.now() }
sessionStorage.setItem(STREAM_ID_STORAGE_KEY, JSON.stringify(data))
logger.info('Stored active stream for potential resumption', { streamId, chatId })
} catch {
// Session storage not available
}
}
/**
* Get stored active stream if one exists
*/
export function getStoredActiveStream(): {
chatId: string
streamId: string
messageId: string
storedAt: number
} | null {
try {
const data = sessionStorage.getItem(STREAM_ID_STORAGE_KEY)
if (!data) return null
return JSON.parse(data)
} catch {
return null
}
}
/**
* Clear stored active stream
*/
export function clearStoredActiveStream(): void {
try {
sessionStorage.removeItem(STREAM_ID_STORAGE_KEY)
} catch {
// Session storage not available
}
}
/**
* Check if a stream is still active
*/
export async function checkStreamStatus(streamId: string): Promise<StreamMetadata | null> {
try {
const response = await fetch(`/api/copilot/stream/${streamId}?mode=poll&offset=0`)
if (!response.ok) {
if (response.status === 404) {
// Stream not found or expired
return null
}
throw new Error(`Failed to check stream status: ${response.statusText}`)
}
const data: StreamResumeResponse = await response.json()
return data.metadata
} catch (error) {
logger.error('Failed to check stream status', { streamId, error })
return null
}
}
/**
* Resume a stream from a given offset using SSE
*/
export async function resumeStream(
streamId: string,
offset: number = 0
): Promise<ReadableStream<Uint8Array> | null> {
try {
const response = await fetch(`/api/copilot/stream/${streamId}?mode=sse&offset=${offset}`)
if (!response.ok || !response.body) {
if (response.status === 404) {
logger.info('Stream not found for resumption', { streamId })
clearStoredActiveStream()
return null
}
throw new Error(`Failed to resume stream: ${response.statusText}`)
}
logger.info('Stream resumption started', { streamId, offset })
return response.body
} catch (error) {
logger.error('Failed to resume stream', { streamId, error })
return null
}
}
/**
* Abort a stream
*/
export async function abortStream(streamId: string): Promise<boolean> {
try {
const response = await fetch(`/api/copilot/stream/${streamId}`, {
method: 'DELETE',
})
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to abort stream: ${response.statusText}`)
}
clearStoredActiveStream()
return true
} catch (error) {
logger.error('Failed to abort stream', { streamId, error })
return false
}
}
export interface StreamSubscription {
unsubscribe: () => void
getStreamId: () => string
}
export interface StreamEventHandler {
onEvent: (event: { type: string; data: Record<string, unknown> }) => void
onError?: (error: Error) => void
onComplete?: () => void
}
/**
* Subscribe to a stream (new or resumed) and process events
* This provides a unified interface for both initial streams and resumed streams
*/
export function subscribeToStream(
streamBody: ReadableStream<Uint8Array>,
handlers: StreamEventHandler
): StreamSubscription {
const reader = streamBody.getReader()
const decoder = new TextDecoder()
let cancelled = false
let buffer = ''
let streamId = ''
const processEvents = async () => {
try {
while (!cancelled) {
const { done, value } = await reader.read()
if (done || cancelled) break
buffer += decoder.decode(value, { stream: true })
// Process complete SSE messages
const messages = buffer.split('\n\n')
buffer = messages.pop() || ''
for (const message of messages) {
if (!message.trim()) continue
if (message.startsWith(':')) continue // SSE comment (ping)
// Parse SSE format
const lines = message.split('\n')
let eventType = 'message'
let data: Record<string, unknown> = {}
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7)
} else if (line.startsWith('data: ')) {
try {
data = JSON.parse(line.slice(6))
} catch {
data = { raw: line.slice(6) }
}
}
}
// Track stream ID if provided in metadata
if (eventType === 'metadata' && data.streamId) {
streamId = data.streamId as string
}
handlers.onEvent({ type: eventType, data })
// Check for terminal events
if (eventType === 'stream_status') {
const status = data.status as string
if (status === 'complete' || status === 'error' || status === 'aborted') {
if (status === 'error' && handlers.onError) {
handlers.onError(new Error(data.error as string || 'Stream error'))
}
if (handlers.onComplete) {
handlers.onComplete()
}
clearStoredActiveStream()
return
}
}
}
}
// Stream ended without explicit status
if (handlers.onComplete) {
handlers.onComplete()
}
} catch (error) {
if (!cancelled && handlers.onError) {
handlers.onError(error instanceof Error ? error : new Error(String(error)))
}
} finally {
reader.releaseLock()
}
}
// Start processing
processEvents()
return {
unsubscribe: () => {
cancelled = true
reader.cancel().catch(() => {})
clearStoredActiveStream()
},
getStreamId: () => streamId,
}
}
/**
* Attempt to resume any active stream from session storage
* Returns handlers if resumption is possible, null otherwise
*/
export async function attemptStreamResumption(): Promise<{
stream: ReadableStream<Uint8Array>
metadata: StreamMetadata
offset: number
} | null> {
const stored = getStoredActiveStream()
if (!stored) return null
// Check if stream is still valid (not too old)
const maxAge = 5 * 60 * 1000 // 5 minutes
if (Date.now() - stored.storedAt > maxAge) {
clearStoredActiveStream()
return null
}
// Check stream status
const metadata = await checkStreamStatus(stored.streamId)
if (!metadata) {
clearStoredActiveStream()
return null
}
// Only resume if stream is still active
if (metadata.status !== 'streaming' && metadata.status !== 'pending') {
clearStoredActiveStream()
return null
}
// Get the stream
const stream = await resumeStream(stored.streamId, 0)
if (!stream) {
return null
}
logger.info('Stream resumption possible', {
streamId: stored.streamId,
status: metadata.status,
})
return { stream, metadata, offset: 0 }
}

View File

@@ -1,327 +0,0 @@
import { createLogger } from '@sim/logger'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('StreamPersistence')
const STREAM_PREFIX = 'copilot:stream:'
const STREAM_TTL = 60 * 60 * 24 // 24 hours
export type StreamStatus = 'pending' | 'streaming' | 'complete' | 'error' | 'aborted'
export interface StreamMetadata {
streamId: string
chatId: string
userId: string
workflowId: string
userMessageId: string
assistantMessageId?: string
status: StreamStatus
isClientSession: boolean
createdAt: number
updatedAt: number
completedAt?: number
error?: string
}
export interface ToolCallState {
id: string
name: string
args: Record<string, unknown>
state: 'pending' | 'executing' | 'success' | 'error'
result?: unknown
error?: string
}
/**
* Initialize a new stream in Redis
*/
export async function createStream(params: {
streamId: string
chatId: string
userId: string
workflowId: string
userMessageId: string
isClientSession: boolean
}): Promise<void> {
const redis = getRedisClient()
if (!redis) {
logger.warn('Redis not available, stream will not be resumable')
return
}
const metadata: StreamMetadata = {
...params,
status: 'pending',
createdAt: Date.now(),
updatedAt: Date.now(),
}
const key = `${STREAM_PREFIX}${params.streamId}:meta`
await redis.set(key, JSON.stringify(metadata), 'EX', STREAM_TTL)
logger.info('Stream created', { streamId: params.streamId })
}
/**
* Update stream status
*/
export async function updateStreamStatus(
streamId: string,
status: StreamStatus,
error?: string
): Promise<void> {
const redis = getRedisClient()
if (!redis) return
const key = `${STREAM_PREFIX}${streamId}:meta`
const data = await redis.get(key)
if (!data) return
const metadata: StreamMetadata = JSON.parse(data)
metadata.status = status
metadata.updatedAt = Date.now()
if (status === 'complete' || status === 'error') {
metadata.completedAt = Date.now()
}
if (error) {
metadata.error = error
}
await redis.set(key, JSON.stringify(metadata), 'EX', STREAM_TTL)
}
/**
* Update stream metadata with additional fields
*/
export async function updateStreamMetadata(
streamId: string,
updates: Partial<StreamMetadata>
): Promise<void> {
const redis = getRedisClient()
if (!redis) return
const key = `${STREAM_PREFIX}${streamId}:meta`
const data = await redis.get(key)
if (!data) return
const metadata: StreamMetadata = JSON.parse(data)
Object.assign(metadata, updates, { updatedAt: Date.now() })
await redis.set(key, JSON.stringify(metadata), 'EX', STREAM_TTL)
}
/**
* Append a serialized SSE event chunk to the stream
*/
export async function appendChunk(streamId: string, chunk: string): Promise<void> {
const redis = getRedisClient()
if (!redis) return
const key = `${STREAM_PREFIX}${streamId}:events`
await redis.rpush(key, chunk)
await redis.expire(key, STREAM_TTL)
}
/**
* Append text content (for quick content retrieval without parsing events)
*/
export async function appendContent(streamId: string, content: string): Promise<void> {
const redis = getRedisClient()
if (!redis) return
const key = `${STREAM_PREFIX}${streamId}:content`
await redis.append(key, content)
await redis.expire(key, STREAM_TTL)
}
/**
* Update tool call state
*/
export async function updateToolCall(
streamId: string,
toolCallId: string,
update: Partial<ToolCallState>
): Promise<void> {
const redis = getRedisClient()
if (!redis) return
const key = `${STREAM_PREFIX}${streamId}:tools`
const existing = await redis.hget(key, toolCallId)
const current: ToolCallState = existing
? JSON.parse(existing)
: { id: toolCallId, name: '', args: {}, state: 'pending' }
const updated = { ...current, ...update }
await redis.hset(key, toolCallId, JSON.stringify(updated))
await redis.expire(key, STREAM_TTL)
}
/**
* Mark stream as complete
*/
export async function completeStream(streamId: string, result?: unknown): Promise<void> {
const redis = getRedisClient()
if (!redis) return
await updateStreamStatus(streamId, 'complete')
if (result !== undefined) {
const key = `${STREAM_PREFIX}${streamId}:result`
await redis.set(key, JSON.stringify(result), 'EX', STREAM_TTL)
}
logger.info('Stream completed', { streamId })
}
/**
* Mark stream as errored
*/
export async function errorStream(streamId: string, error: string): Promise<void> {
await updateStreamStatus(streamId, 'error', error)
logger.error('Stream errored', { streamId, error })
}
/**
* Check if stream was aborted (client requested abort)
*/
export async function checkAbortSignal(streamId: string): Promise<boolean> {
const redis = getRedisClient()
if (!redis) return false
const key = `${STREAM_PREFIX}${streamId}:abort`
const aborted = await redis.exists(key)
return aborted === 1
}
/**
* Signal stream abort
*/
export async function abortStream(streamId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) return
await redis.set(`${STREAM_PREFIX}${streamId}:abort`, '1', 'EX', STREAM_TTL)
await updateStreamStatus(streamId, 'aborted')
logger.info('Stream aborted', { streamId })
}
/**
* Refresh TTL on all stream keys
*/
export async function refreshStreamTTL(streamId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) return
const keys = [
`${STREAM_PREFIX}${streamId}:meta`,
`${STREAM_PREFIX}${streamId}:events`,
`${STREAM_PREFIX}${streamId}:content`,
`${STREAM_PREFIX}${streamId}:tools`,
`${STREAM_PREFIX}${streamId}:result`,
]
for (const key of keys) {
await redis.expire(key, STREAM_TTL)
}
}
/**
* Get stream metadata
*/
export async function getStreamMetadata(streamId: string): Promise<StreamMetadata | null> {
const redis = getRedisClient()
if (!redis) return null
const data = await redis.get(`${STREAM_PREFIX}${streamId}:meta`)
return data ? JSON.parse(data) : null
}
/**
* Get stream events from offset (for resumption)
*/
export async function getStreamEvents(streamId: string, fromOffset: number = 0): Promise<string[]> {
const redis = getRedisClient()
if (!redis) return []
const key = `${STREAM_PREFIX}${streamId}:events`
return redis.lrange(key, fromOffset, -1)
}
/**
* Get current event count (for client to know where it is)
*/
export async function getStreamEventCount(streamId: string): Promise<number> {
const redis = getRedisClient()
if (!redis) return 0
const key = `${STREAM_PREFIX}${streamId}:events`
return redis.llen(key)
}
/**
* Get all tool call states
*/
export async function getToolCallStates(streamId: string): Promise<Record<string, ToolCallState>> {
const redis = getRedisClient()
if (!redis) return {}
const key = `${STREAM_PREFIX}${streamId}:tools`
const data = await redis.hgetall(key)
const result: Record<string, ToolCallState> = {}
for (const [id, json] of Object.entries(data)) {
result[id] = JSON.parse(json)
}
return result
}
/**
* Get accumulated content
*/
export async function getStreamContent(streamId: string): Promise<string> {
const redis = getRedisClient()
if (!redis) return ''
const key = `${STREAM_PREFIX}${streamId}:content`
return (await redis.get(key)) || ''
}
/**
* Get final result (if complete)
*/
export async function getStreamResult(streamId: string): Promise<unknown | null> {
const redis = getRedisClient()
if (!redis) return null
const key = `${STREAM_PREFIX}${streamId}:result`
const data = await redis.get(key)
return data ? JSON.parse(data) : null
}
/**
* Check if Redis is available for stream persistence
*/
export function isStreamPersistenceEnabled(): boolean {
return getRedisClient() !== null
}
/**
* Delete all stream data (cleanup)
*/
export async function deleteStream(streamId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) return
const keys = [
`${STREAM_PREFIX}${streamId}:meta`,
`${STREAM_PREFIX}${streamId}:events`,
`${STREAM_PREFIX}${streamId}:content`,
`${STREAM_PREFIX}${streamId}:tools`,
`${STREAM_PREFIX}${streamId}:result`,
`${STREAM_PREFIX}${streamId}:abort`,
]
await redis.del(...keys)
logger.info('Stream deleted', { streamId })
}

View File

@@ -1,419 +0,0 @@
import { createLogger } from '@sim/logger'
import type { RenderEvent } from './render-events'
const logger = createLogger('StreamTransformer')
export interface TransformStreamContext {
streamId: string
chatId: string
userId: string
workflowId: string
userMessageId: string
assistantMessageId: string
/** Callback for each render event - handles both client delivery and persistence */
onRenderEvent: (event: RenderEvent) => Promise<void>
/** Callback for persistence operations */
onPersist?: (data: { type: string; [key: string]: unknown }) => Promise<void>
/** Check if stream should be aborted */
isAborted: () => boolean | Promise<boolean>
}
interface SimAgentEvent {
type?: string
event?: string
data?: unknown
[key: string]: unknown
}
/**
* Transform a sim agent SSE stream into normalized render events.
* This function consumes the entire stream and emits events via callbacks.
*/
export async function transformStream(
body: ReadableStream<Uint8Array>,
context: TransformStreamContext
): Promise<void> {
const { onRenderEvent, onPersist, isAborted } = context
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
// Check abort signal
const shouldAbort = await Promise.resolve(isAborted())
if (shouldAbort) {
logger.info('Stream aborted by signal', { streamId: context.streamId })
break
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Process complete SSE messages (separated by double newlines)
const messages = buffer.split('\n\n')
buffer = messages.pop() || '' // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim()) continue
const events = parseSimAgentMessage(message)
for (const simEvent of events) {
const renderEvents = transformSimAgentEvent(simEvent, context)
for (const renderEvent of renderEvents) {
await onRenderEvent(renderEvent)
}
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
const events = parseSimAgentMessage(buffer)
for (const simEvent of events) {
const renderEvents = transformSimAgentEvent(simEvent, context)
for (const renderEvent of renderEvents) {
await onRenderEvent(renderEvent)
}
}
}
// Emit message complete
await onRenderEvent({
type: 'message_complete',
messageId: context.assistantMessageId,
timestamp: Date.now(),
})
// Notify persistence layer
if (onPersist) {
await onPersist({ type: 'message_complete', messageId: context.assistantMessageId })
}
} catch (error) {
logger.error('Stream transform error', { streamId: context.streamId, error })
await onRenderEvent({
type: 'error',
error: error instanceof Error ? error.message : 'Stream processing error',
timestamp: Date.now(),
})
throw error
} finally {
reader.releaseLock()
}
}
/**
* Parse a raw SSE message into sim agent events
*/
function parseSimAgentMessage(message: string): SimAgentEvent[] {
const events: SimAgentEvent[] = []
const lines = message.split('\n')
let currentEvent: string | null = null
let currentData: string[] = []
for (const line of lines) {
if (line.startsWith('event: ')) {
// If we have accumulated data, emit previous event
if (currentData.length > 0) {
const dataStr = currentData.join('\n')
const parsed = tryParseJson(dataStr)
if (parsed) {
events.push({ ...parsed, event: currentEvent || undefined })
}
currentData = []
}
currentEvent = line.slice(7)
} else if (line.startsWith('data: ')) {
currentData.push(line.slice(6))
} else if (line === '' && currentData.length > 0) {
// Empty line signals end of event
const dataStr = currentData.join('\n')
const parsed = tryParseJson(dataStr)
if (parsed) {
events.push({ ...parsed, event: currentEvent || undefined })
}
currentEvent = null
currentData = []
}
}
// Handle remaining data
if (currentData.length > 0) {
const dataStr = currentData.join('\n')
const parsed = tryParseJson(dataStr)
if (parsed) {
events.push({ ...parsed, event: currentEvent || undefined })
}
}
return events
}
function tryParseJson(str: string): Record<string, unknown> | null {
if (str === '[DONE]') return null
try {
return JSON.parse(str)
} catch {
return null
}
}
/**
* Transform a sim agent event into one or more render events
*/
function transformSimAgentEvent(
simEvent: SimAgentEvent,
context: TransformStreamContext
): RenderEvent[] {
const eventType = simEvent.type || simEvent.event
const events: RenderEvent[] = []
const timestamp = Date.now()
switch (eventType) {
// Text content events
case 'content_block_delta':
case 'text_delta':
case 'delta': {
const delta = (simEvent.delta as Record<string, unknown>) || simEvent
const text = (delta.text as string) || (delta.content as string) || (simEvent.text as string)
if (text) {
events.push({ type: 'text_delta', content: text, timestamp })
}
break
}
case 'content_block_stop':
case 'text_complete': {
events.push({
type: 'text_complete',
content: (simEvent.content as string) || '',
timestamp,
})
break
}
// Tool call events
case 'tool_call':
case 'tool_use': {
const data = (simEvent.data as Record<string, unknown>) || simEvent
const toolCallId = (data.id as string) || (simEvent.id as string)
const toolName = (data.name as string) || (simEvent.name as string)
const args = (data.arguments as Record<string, unknown>) || (data.input as Record<string, unknown>)
if (toolCallId && toolName) {
events.push({
type: 'tool_pending',
toolCallId,
toolName,
args,
timestamp,
})
}
break
}
case 'tool_executing': {
const toolCallId = (simEvent.toolCallId as string) || (simEvent.id as string)
const toolName = (simEvent.toolName as string) || (simEvent.name as string) || ''
if (toolCallId) {
events.push({
type: 'tool_executing',
toolCallId,
toolName,
timestamp,
})
}
break
}
case 'tool_result': {
const toolCallId = (simEvent.toolCallId as string) || (simEvent.id as string)
const success = simEvent.success as boolean
const result = simEvent.result
const error = simEvent.error as string | undefined
if (toolCallId) {
events.push({
type: 'tool_result',
toolCallId,
success: success !== false,
result,
error,
failedDependency: simEvent.failedDependency as boolean | undefined,
skipped: (simEvent.result as Record<string, unknown>)?.skipped as boolean | undefined,
timestamp,
})
// Also emit success/error event for UI
if (success !== false) {
events.push({
type: 'tool_success',
toolCallId,
toolName: (simEvent.toolName as string) || '',
result,
timestamp,
})
} else {
events.push({
type: 'tool_error',
toolCallId,
toolName: (simEvent.toolName as string) || '',
error: error || 'Tool execution failed',
timestamp,
})
}
}
break
}
// Subagent events
case 'subagent_start': {
events.push({
type: 'subagent_start',
parentToolCallId: simEvent.parentToolCallId as string,
subagentName: simEvent.subagentName as string,
timestamp,
})
break
}
case 'subagent_text':
case 'subagent_delta': {
events.push({
type: 'subagent_text',
parentToolCallId: simEvent.parentToolCallId as string,
content: (simEvent.content as string) || (simEvent.text as string) || '',
timestamp,
})
break
}
case 'subagent_tool_call': {
events.push({
type: 'subagent_tool_call',
parentToolCallId: simEvent.parentToolCallId as string,
toolCallId: simEvent.toolCallId as string,
toolName: simEvent.toolName as string,
args: simEvent.args as Record<string, unknown> | undefined,
state: (simEvent.state as 'pending' | 'executing' | 'success' | 'error') || 'pending',
result: simEvent.result,
error: simEvent.error as string | undefined,
timestamp,
})
break
}
case 'subagent_end': {
events.push({
type: 'subagent_end',
parentToolCallId: simEvent.parentToolCallId as string,
timestamp,
})
break
}
// Thinking events (for extended thinking models)
case 'thinking_start':
case 'thinking': {
if (simEvent.type === 'thinking_start' || !simEvent.content) {
events.push({ type: 'thinking_start', timestamp })
}
if (simEvent.content) {
events.push({
type: 'thinking_delta',
content: simEvent.content as string,
timestamp,
})
}
break
}
case 'thinking_delta': {
events.push({
type: 'thinking_delta',
content: (simEvent.content as string) || '',
timestamp,
})
break
}
case 'thinking_end':
case 'thinking_complete': {
events.push({ type: 'thinking_end', timestamp })
break
}
// Message lifecycle events
case 'message_start': {
events.push({
type: 'message_start',
messageId: (simEvent.messageId as string) || context.assistantMessageId,
timestamp,
})
break
}
case 'message_stop':
case 'message_complete':
case 'message_delta': {
if (eventType === 'message_complete' || eventType === 'message_stop') {
events.push({
type: 'message_complete',
messageId: (simEvent.messageId as string) || context.assistantMessageId,
content: simEvent.content as string | undefined,
timestamp,
})
}
break
}
// Metadata events
case 'chat_id': {
events.push({
type: 'chat_id',
chatId: simEvent.chatId as string,
timestamp,
})
break
}
case 'conversation_id': {
events.push({
type: 'conversation_id',
conversationId: simEvent.conversationId as string,
timestamp,
})
break
}
// Error events
case 'error': {
events.push({
type: 'error',
error: (simEvent.error as string) || (simEvent.message as string) || 'Unknown error',
code: simEvent.code as string | undefined,
timestamp,
})
break
}
default: {
// Log unhandled event types for debugging
if (eventType && eventType !== 'ping') {
logger.debug('Unhandled sim agent event type', { eventType, streamId: context.streamId })
}
}
}
return events
}

View File

@@ -10,7 +10,7 @@ import {
GetBlockConfigInput,
GetBlockConfigResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockConfigArgs {
blockType: string
@@ -40,8 +40,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
},
getDynamicText: (params, state) => {
if (params?.blockType && typeof params.blockType === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(params.blockType)
const blockConfig = getLatestBlock(params.blockType)
const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase()
const opSuffix = params.operation ? ` (${params.operation})` : ''

View File

@@ -10,7 +10,7 @@ import {
GetBlockOptionsInput,
GetBlockOptionsResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockOptionsArgs {
blockId: string
@@ -43,8 +43,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
(params as any)?.block_id ||
(params as any)?.block_type
if (typeof blockId === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(blockId)
const blockConfig = getLatestBlock(blockId)
const blockName = (blockConfig?.name ?? blockId.replace(/_/g, ' ')).toLowerCase()
switch (state) {

View File

@@ -5,7 +5,7 @@ import {
GetBlockConfigResult,
type GetBlockConfigResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
@@ -452,9 +452,12 @@ export const getBlockConfigServerTool: BaseServerTool<
const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
const outputs = extractOutputs(blockConfig, operation, trigger)
const latestBlock = getLatestBlock(blockType)
const displayName = latestBlock?.name ?? blockConfig.name
const result = {
blockType,
blockName: blockConfig.name,
blockName: displayName,
operation,
trigger,
inputs,

View File

@@ -5,7 +5,7 @@ import {
GetBlockOptionsResult,
type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { tools as toolsRegistry } from '@/tools/registry'
@@ -113,9 +113,12 @@ export const getBlockOptionsServerTool: BaseServerTool<
}
}
const latestBlock = getLatestBlock(blockId)
const displayName = latestBlock?.name ?? blockConfig.name
const result = {
blockId,
blockName: blockConfig.name,
blockName: displayName,
operations,
}

View File

@@ -1,255 +0,0 @@
import { createLogger } from '@sim/logger'
import { routeExecution } from './router'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
const logger = createLogger('ServerToolExecutor')
export interface ServerToolContext {
workflowId: string
userId: string
persistChanges?: boolean
}
export interface ServerToolResult {
success: boolean
result?: unknown
error?: string
}
/**
* Execute any copilot tool completely server-side.
* This is the central dispatcher for headless/API operation.
*/
export async function executeToolServerSide(
toolCall: { name: string; args: Record<string, unknown> },
context: ServerToolContext
): Promise<ServerToolResult> {
const { name, args } = toolCall
const { workflowId, userId, persistChanges = true } = context
logger.info('Executing tool server-side', { name, workflowId, userId })
try {
const result = await executeToolInternal(name, args, context)
return { success: true, result }
} catch (error) {
logger.error('Server-side tool execution failed', {
name,
workflowId,
error: error instanceof Error ? error.message : String(error),
})
return {
success: false,
error: error instanceof Error ? error.message : 'Tool execution failed',
}
}
}
async function executeToolInternal(
name: string,
args: Record<string, unknown>,
context: ServerToolContext
): Promise<unknown> {
const { workflowId, userId, persistChanges = true } = context
switch (name) {
case 'edit_workflow': {
// Execute edit_workflow with direct persistence
const result = await routeExecution(
'edit_workflow',
{
...args,
workflowId,
// Don't require currentUserWorkflow - server tool will load from DB
},
{ userId }
)
// Persist directly to database if enabled
if (persistChanges && result.workflowState) {
try {
await saveWorkflowToNormalizedTables(workflowId, result.workflowState)
logger.info('Workflow changes persisted directly', { workflowId })
} catch (error) {
logger.error('Failed to persist workflow changes', { error, workflowId })
// Don't throw - return the result anyway
}
}
return result
}
case 'run_workflow': {
// Import dynamically to avoid circular dependencies
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
const result = await executeWorkflow({
workflowId,
input: (args.workflow_input as Record<string, unknown>) || {},
isClientSession: false,
})
return result
}
case 'deploy_api':
case 'deploy_chat':
case 'deploy_mcp': {
// Import dynamically
const { deployWorkflow } = await import('@/lib/workflows/persistence/utils')
const deployType = name.replace('deploy_', '')
const result = await deployWorkflow({
workflowId,
deployedBy: userId,
})
return { ...result, deployType }
}
case 'redeploy': {
const { deployWorkflow } = await import('@/lib/workflows/persistence/utils')
const result = await deployWorkflow({
workflowId,
deployedBy: userId,
})
return result
}
// Server tools that already exist in the router
case 'get_blocks_and_tools':
case 'get_blocks_metadata':
case 'get_block_options':
case 'get_block_config':
case 'get_trigger_blocks':
case 'get_workflow_console':
case 'search_documentation':
case 'search_online':
case 'set_environment_variables':
case 'get_credentials':
case 'make_api_request':
case 'knowledge_base': {
return routeExecution(name, args, { userId })
}
// Tools that just need workflowId context
case 'get_user_workflow':
case 'get_workflow_data': {
const { loadWorkflowFromNormalizedTables } = await import(
'@/lib/workflows/persistence/utils'
)
const { sanitizeForCopilot } = await import('@/lib/workflows/sanitization/json-sanitizer')
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
if (!workflowData) {
throw new Error('Workflow not found')
}
const sanitized = sanitizeForCopilot({
blocks: workflowData.blocks,
edges: workflowData.edges,
loops: workflowData.loops,
parallels: workflowData.parallels,
})
return { workflow: JSON.stringify(sanitized, null, 2) }
}
case 'list_user_workflows': {
const { db } = await import('@sim/db')
const { workflow: workflowTable } = await import('@sim/db/schema')
const { eq } = await import('drizzle-orm')
const workflows = await db
.select({
id: workflowTable.id,
name: workflowTable.name,
description: workflowTable.description,
isDeployed: workflowTable.isDeployed,
createdAt: workflowTable.createdAt,
updatedAt: workflowTable.updatedAt,
})
.from(workflowTable)
.where(eq(workflowTable.userId, userId))
return { workflows }
}
case 'check_deployment_status': {
const { db } = await import('@sim/db')
const { workflow: workflowTable } = await import('@sim/db/schema')
const { eq } = await import('drizzle-orm')
const [wf] = await db
.select({
isDeployed: workflowTable.isDeployed,
deployedAt: workflowTable.deployedAt,
})
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
return {
isDeployed: wf?.isDeployed || false,
deployedAt: wf?.deployedAt || null,
}
}
default: {
logger.warn('Unknown tool for server-side execution', { name })
throw new Error(`Tool ${name} is not available for server-side execution`)
}
}
}
/**
* Check if a tool can be executed server-side
*/
export function isServerExecutableTool(toolName: string): boolean {
const serverExecutableTools = new Set([
// Core editing tools
'edit_workflow',
'run_workflow',
// Deployment tools
'deploy_api',
'deploy_chat',
'deploy_mcp',
'redeploy',
'check_deployment_status',
// Existing server tools
'get_blocks_and_tools',
'get_blocks_metadata',
'get_block_options',
'get_block_config',
'get_trigger_blocks',
'get_workflow_console',
'search_documentation',
'search_online',
'set_environment_variables',
'get_credentials',
'make_api_request',
'knowledge_base',
// Workflow info tools
'get_user_workflow',
'get_workflow_data',
'list_user_workflows',
])
return serverExecutableTools.has(toolName)
}
/**
* Get list of tools that require client-side execution
*/
export function getClientOnlyTools(): string[] {
return [
'navigate_ui', // Requires DOM
'oauth_request_access', // Requires browser auth flow
]
}

View File

@@ -1,9 +1,9 @@
import { loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
import {
createPinnedUrl,
validateAirtableId,
validateAlphanumericId,
validateAwsRegion,
validateEnum,
validateExternalUrl,
validateFileExtension,
@@ -17,6 +17,7 @@ import {
validateNumericId,
validatePathSegment,
validateProxyUrl,
validateS3BucketName,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation'
import { sanitizeForLogging } from '@/lib/core/security/redaction'
@@ -592,28 +593,6 @@ describe('validateUrlWithDNS', () => {
})
})
describe('createPinnedUrl', () => {
it('should replace hostname with IP', () => {
const result = createPinnedUrl('https://example.com/api/data', '93.184.216.34')
expect(result).toBe('https://93.184.216.34/api/data')
})
it('should preserve port if specified', () => {
const result = createPinnedUrl('https://example.com:8443/api', '93.184.216.34')
expect(result).toBe('https://93.184.216.34:8443/api')
})
it('should preserve query string', () => {
const result = createPinnedUrl('https://example.com/api?foo=bar&baz=qux', '93.184.216.34')
expect(result).toBe('https://93.184.216.34/api?foo=bar&baz=qux')
})
it('should preserve path', () => {
const result = createPinnedUrl('https://example.com/a/b/c/d', '93.184.216.34')
expect(result).toBe('https://93.184.216.34/a/b/c/d')
})
})
describe('validateInteger', () => {
describe('valid integers', () => {
it.concurrent('should accept positive integers', () => {
@@ -929,13 +908,13 @@ describe('validateExternalUrl', () => {
it.concurrent('should reject 127.0.0.1', () => {
const result = validateExternalUrl('https://127.0.0.1/api')
expect(result.isValid).toBe(false)
expect(result.error).toContain('localhost')
expect(result.error).toContain('private IP')
})
it.concurrent('should reject 0.0.0.0', () => {
const result = validateExternalUrl('https://0.0.0.0/api')
expect(result.isValid).toBe(false)
expect(result.error).toContain('localhost')
expect(result.error).toContain('private IP')
})
})
@@ -1192,3 +1171,282 @@ describe('validateAirtableId', () => {
})
})
})
describe('validateAwsRegion', () => {
describe('valid standard regions', () => {
it.concurrent('should accept us-east-1', () => {
const result = validateAwsRegion('us-east-1')
expect(result.isValid).toBe(true)
expect(result.sanitized).toBe('us-east-1')
})
it.concurrent('should accept us-west-2', () => {
const result = validateAwsRegion('us-west-2')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept eu-west-1', () => {
const result = validateAwsRegion('eu-west-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept eu-central-1', () => {
const result = validateAwsRegion('eu-central-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept ap-southeast-1', () => {
const result = validateAwsRegion('ap-southeast-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept ap-northeast-1', () => {
const result = validateAwsRegion('ap-northeast-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept sa-east-1', () => {
const result = validateAwsRegion('sa-east-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept me-south-1', () => {
const result = validateAwsRegion('me-south-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept af-south-1', () => {
const result = validateAwsRegion('af-south-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept ca-central-1', () => {
const result = validateAwsRegion('ca-central-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept il-central-1', () => {
const result = validateAwsRegion('il-central-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept regions with double-digit numbers', () => {
const result = validateAwsRegion('ap-northeast-12')
expect(result.isValid).toBe(true)
})
})
describe('valid GovCloud regions', () => {
it.concurrent('should accept us-gov-west-1', () => {
const result = validateAwsRegion('us-gov-west-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept us-gov-east-1', () => {
const result = validateAwsRegion('us-gov-east-1')
expect(result.isValid).toBe(true)
})
})
describe('valid China regions', () => {
it.concurrent('should accept cn-north-1', () => {
const result = validateAwsRegion('cn-north-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept cn-northwest-1', () => {
const result = validateAwsRegion('cn-northwest-1')
expect(result.isValid).toBe(true)
})
})
describe('valid ISO regions', () => {
it.concurrent('should accept us-iso-east-1', () => {
const result = validateAwsRegion('us-iso-east-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept us-isob-east-1', () => {
const result = validateAwsRegion('us-isob-east-1')
expect(result.isValid).toBe(true)
})
})
describe('invalid regions', () => {
it.concurrent('should reject null', () => {
const result = validateAwsRegion(null)
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
it.concurrent('should reject empty string', () => {
const result = validateAwsRegion('')
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
it.concurrent('should reject uppercase regions', () => {
const result = validateAwsRegion('US-EAST-1')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid format - missing number', () => {
const result = validateAwsRegion('us-east')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid format - wrong separators', () => {
const result = validateAwsRegion('us_east_1')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid format - too many parts', () => {
const result = validateAwsRegion('us-east-1-extra')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject path traversal attempts', () => {
const result = validateAwsRegion('../etc/passwd')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject arbitrary strings', () => {
const result = validateAwsRegion('not-a-region')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid prefix', () => {
const result = validateAwsRegion('xx-east-1')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid direction', () => {
const result = validateAwsRegion('us-middle-1')
expect(result.isValid).toBe(false)
})
it.concurrent('should use custom param name in errors', () => {
const result = validateAwsRegion('', 'awsRegion')
expect(result.error).toContain('awsRegion')
})
})
})
describe('validateS3BucketName', () => {
describe('valid bucket names', () => {
it.concurrent('should accept simple bucket name', () => {
const result = validateS3BucketName('my-bucket')
expect(result.isValid).toBe(true)
expect(result.sanitized).toBe('my-bucket')
})
it.concurrent('should accept bucket name with numbers', () => {
const result = validateS3BucketName('bucket123')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept bucket name with periods', () => {
const result = validateS3BucketName('my.bucket.name')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept 3 character bucket name', () => {
const result = validateS3BucketName('abc')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept 63 character bucket name', () => {
const result = validateS3BucketName('a'.repeat(63))
expect(result.isValid).toBe(true)
})
it.concurrent('should accept minimum valid bucket name (3 chars)', () => {
const result = validateS3BucketName('a1b')
expect(result.isValid).toBe(true)
})
})
describe('invalid bucket names - null/empty', () => {
it.concurrent('should reject null', () => {
const result = validateS3BucketName(null)
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
it.concurrent('should reject empty string', () => {
const result = validateS3BucketName('')
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
})
describe('invalid bucket names - length', () => {
it.concurrent('should reject 2 character bucket name', () => {
const result = validateS3BucketName('ab')
expect(result.isValid).toBe(false)
expect(result.error).toContain('between 3 and 63')
})
it.concurrent('should reject 64 character bucket name', () => {
const result = validateS3BucketName('a'.repeat(64))
expect(result.isValid).toBe(false)
expect(result.error).toContain('between 3 and 63')
})
})
describe('invalid bucket names - format', () => {
it.concurrent('should reject uppercase letters', () => {
const result = validateS3BucketName('MyBucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject underscores', () => {
const result = validateS3BucketName('my_bucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject starting with hyphen', () => {
const result = validateS3BucketName('-mybucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject ending with hyphen', () => {
const result = validateS3BucketName('mybucket-')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject starting with period', () => {
const result = validateS3BucketName('.mybucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject ending with period', () => {
const result = validateS3BucketName('mybucket.')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject consecutive periods', () => {
const result = validateS3BucketName('my..bucket')
expect(result.isValid).toBe(false)
expect(result.error).toContain('consecutive periods')
})
it.concurrent('should reject IP address format', () => {
const result = validateS3BucketName('192.168.1.1')
expect(result.isValid).toBe(false)
expect(result.error).toContain('IP address')
})
it.concurrent('should reject special characters', () => {
const result = validateS3BucketName('my@bucket')
expect(result.isValid).toBe(false)
})
})
describe('error messages', () => {
it.concurrent('should use custom param name in errors', () => {
const result = validateS3BucketName('', 's3Bucket')
expect(result.error).toContain('s3Bucket')
})
})
})

View File

@@ -1,20 +1,17 @@
import dns from 'dns/promises'
import http from 'http'
import https from 'https'
import { createLogger } from '@sim/logger'
import * as ipaddr from 'ipaddr.js'
const logger = createLogger('InputValidation')
/**
* Result type for validation functions
*/
export interface ValidationResult {
isValid: boolean
error?: string
sanitized?: string
}
/**
* Options for path segment validation
*/
export interface PathSegmentOptions {
/** Name of the parameter for error messages */
paramName?: string
@@ -65,7 +62,6 @@ export function validatePathSegment(
customPattern,
} = options
// Check for null/undefined
if (value === null || value === undefined || value === '') {
return {
isValid: false,
@@ -73,7 +69,6 @@ export function validatePathSegment(
}
}
// Check length
if (value.length > maxLength) {
logger.warn('Path segment exceeds maximum length', {
paramName,
@@ -86,7 +81,6 @@ export function validatePathSegment(
}
}
// Check for null bytes (potential for bypass attacks)
if (value.includes('\0') || value.includes('%00')) {
logger.warn('Path segment contains null bytes', { paramName })
return {
@@ -95,7 +89,6 @@ export function validatePathSegment(
}
}
// Check for path traversal patterns
const pathTraversalPatterns = [
'..',
'./',
@@ -124,7 +117,6 @@ export function validatePathSegment(
}
}
// Check for directory separators
if (value.includes('/') || value.includes('\\')) {
logger.warn('Path segment contains directory separators', { paramName })
return {
@@ -133,7 +125,6 @@ export function validatePathSegment(
}
}
// Use custom pattern if provided
if (customPattern) {
if (!customPattern.test(value)) {
logger.warn('Path segment failed custom pattern validation', {
@@ -148,7 +139,6 @@ export function validatePathSegment(
return { isValid: true, sanitized: value }
}
// Build allowed character pattern
let pattern = '^[a-zA-Z0-9'
if (allowHyphens) pattern += '\\-'
if (allowUnderscores) pattern += '_'
@@ -402,42 +392,20 @@ export function validateHostname(
}
}
// Import the blocked IP ranges from url-validation
const BLOCKED_IP_RANGES = [
// Private IPv4 ranges (RFC 1918)
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
// Loopback addresses
/^127\./,
/^localhost$/i,
// Link-local addresses (RFC 3927)
/^169\.254\./,
// Cloud metadata endpoints
/^169\.254\.169\.254$/,
// Broadcast and other reserved ranges
/^0\./,
/^224\./,
/^240\./,
/^255\./,
// IPv6 loopback and link-local
/^::1$/,
/^fe80:/i,
/^::ffff:127\./i,
/^::ffff:10\./i,
/^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i,
/^::ffff:192\.168\./i,
]
const lowerHostname = hostname.toLowerCase()
for (const pattern of BLOCKED_IP_RANGES) {
if (pattern.test(lowerHostname)) {
// Block localhost
if (lowerHostname === 'localhost') {
logger.warn('Hostname is localhost', { paramName })
return {
isValid: false,
error: `${paramName} cannot be a private IP address or localhost`,
}
}
// Use ipaddr.js to check if hostname is an IP and if it's private/reserved
if (ipaddr.isValid(lowerHostname)) {
if (isPrivateOrReservedIP(lowerHostname)) {
logger.warn('Hostname matches blocked IP range', {
paramName,
hostname: hostname.substring(0, 100),
@@ -710,33 +678,17 @@ export function validateExternalUrl(
// Block private IP ranges and localhost
const hostname = parsedUrl.hostname.toLowerCase()
// Block localhost variations
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname.startsWith('127.') ||
hostname === '0.0.0.0'
) {
// Block localhost
if (hostname === 'localhost') {
return {
isValid: false,
error: `${paramName} cannot point to localhost`,
}
}
// Block private IP ranges
const privateIpPatterns = [
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./, // Link-local
/^fe80:/i, // IPv6 link-local
/^fc00:/i, // IPv6 unique local
/^fd00:/i, // IPv6 unique local
]
for (const pattern of privateIpPatterns) {
if (pattern.test(hostname)) {
// Use ipaddr.js to check if hostname is an IP and if it's private/reserved
if (ipaddr.isValid(hostname)) {
if (isPrivateOrReservedIP(hostname)) {
return {
isValid: false,
error: `${paramName} cannot point to private IP addresses`,
@@ -791,30 +743,25 @@ export function validateProxyUrl(
/**
* Checks if an IP address is private or reserved (not routable on the public internet)
* Uses ipaddr.js for robust handling of all IP formats including:
* - Octal notation (0177.0.0.1)
* - Hex notation (0x7f000001)
* - IPv4-mapped IPv6 (::ffff:127.0.0.1)
* - Various edge cases that regex patterns miss
*/
function isPrivateOrReservedIP(ip: string): boolean {
const patterns = [
/^127\./, // Loopback
/^10\./, // Private Class A
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B
/^192\.168\./, // Private Class C
/^169\.254\./, // Link-local
/^0\./, // Current network
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // Carrier-grade NAT
/^192\.0\.0\./, // IETF Protocol Assignments
/^192\.0\.2\./, // TEST-NET-1
/^198\.51\.100\./, // TEST-NET-2
/^203\.0\.113\./, // TEST-NET-3
/^224\./, // Multicast
/^240\./, // Reserved
/^255\./, // Broadcast
/^::1$/, // IPv6 loopback
/^fe80:/i, // IPv6 link-local
/^fc00:/i, // IPv6 unique local
/^fd00:/i, // IPv6 unique local
/^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|169\.254\.)/i, // IPv4-mapped IPv6
]
return patterns.some((pattern) => pattern.test(ip))
try {
if (!ipaddr.isValid(ip)) {
return true
}
const addr = ipaddr.process(ip)
const range = addr.range()
return range !== 'unicast'
} catch {
return true
}
}
/**
@@ -882,18 +829,194 @@ export async function validateUrlWithDNS(
}
}
}
export interface SecureFetchOptions {
method?: string
headers?: Record<string, string>
body?: string
timeout?: number
maxRedirects?: number
}
export class SecureFetchHeaders {
private headers: Map<string, string>
constructor(headers: Record<string, string>) {
this.headers = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
}
get(name: string): string | null {
return this.headers.get(name.toLowerCase()) ?? null
}
toRecord(): Record<string, string> {
const record: Record<string, string> = {}
for (const [key, value] of this.headers) {
record[key] = value
}
return record
}
[Symbol.iterator]() {
return this.headers.entries()
}
}
export interface SecureFetchResponse {
ok: boolean
status: number
statusText: string
headers: SecureFetchHeaders
text: () => Promise<string>
json: () => Promise<unknown>
arrayBuffer: () => Promise<ArrayBuffer>
}
const DEFAULT_MAX_REDIRECTS = 5
function isRedirectStatus(status: number): boolean {
return status >= 300 && status < 400 && status !== 304
}
function resolveRedirectUrl(baseUrl: string, location: string): string {
try {
return new URL(location, baseUrl).toString()
} catch {
throw new Error(`Invalid redirect location: ${location}`)
}
}
/**
* Creates a fetch URL that uses a resolved IP address to prevent DNS rebinding
*
* @param originalUrl - The original URL
* @param resolvedIP - The resolved IP address to use
* @returns The URL with IP substituted for hostname
* Performs a fetch with IP pinning to prevent DNS rebinding attacks.
* Uses the pre-resolved IP address while preserving the original hostname for TLS SNI.
* Follows redirects securely by validating each redirect target.
*/
export function createPinnedUrl(originalUrl: string, resolvedIP: string): string {
const parsed = new URL(originalUrl)
const port = parsed.port ? `:${parsed.port}` : ''
return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}`
export async function secureFetchWithPinnedIP(
url: string,
resolvedIP: string,
options: SecureFetchOptions = {},
redirectCount = 0
): Promise<SecureFetchResponse> {
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const isHttps = parsed.protocol === 'https:'
const defaultPort = isHttps ? 443 : 80
const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort
const isIPv6 = resolvedIP.includes(':')
const family = isIPv6 ? 6 : 4
const agentOptions = {
lookup: (
_hostname: string,
_options: unknown,
callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void
) => {
callback(null, resolvedIP, family)
},
}
const agent = isHttps
? new https.Agent(agentOptions as https.AgentOptions)
: new http.Agent(agentOptions as http.AgentOptions)
const requestOptions: http.RequestOptions = {
hostname: parsed.hostname,
port,
path: parsed.pathname + parsed.search,
method: options.method || 'GET',
headers: options.headers || {},
agent,
timeout: options.timeout || 30000,
}
const protocol = isHttps ? https : http
const req = protocol.request(requestOptions, (res) => {
const statusCode = res.statusCode || 0
const location = res.headers.location
if (isRedirectStatus(statusCode) && location && redirectCount < maxRedirects) {
res.resume()
const redirectUrl = resolveRedirectUrl(url, location)
validateUrlWithDNS(redirectUrl, 'redirectUrl')
.then((validation) => {
if (!validation.isValid) {
reject(new Error(`Redirect blocked: ${validation.error}`))
return
}
return secureFetchWithPinnedIP(
redirectUrl,
validation.resolvedIP!,
options,
redirectCount + 1
)
})
.then((response) => {
if (response) resolve(response)
})
.catch(reject)
return
}
if (isRedirectStatus(statusCode) && location && redirectCount >= maxRedirects) {
res.resume()
reject(new Error(`Too many redirects (max: ${maxRedirects})`))
return
}
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('error', (error) => {
reject(error)
})
res.on('end', () => {
const bodyBuffer = Buffer.concat(chunks)
const body = bodyBuffer.toString('utf-8')
const headersRecord: Record<string, string> = {}
for (const [key, value] of Object.entries(res.headers)) {
if (typeof value === 'string') {
headersRecord[key.toLowerCase()] = value
} else if (Array.isArray(value)) {
headersRecord[key.toLowerCase()] = value.join(', ')
}
}
resolve({
ok: statusCode >= 200 && statusCode < 300,
status: statusCode,
statusText: res.statusMessage || '',
headers: new SecureFetchHeaders(headersRecord),
text: async () => body,
json: async () => JSON.parse(body),
arrayBuffer: async () =>
bodyBuffer.buffer.slice(
bodyBuffer.byteOffset,
bodyBuffer.byteOffset + bodyBuffer.byteLength
),
})
})
})
req.on('error', (error) => {
reject(error)
})
req.on('timeout', () => {
req.destroy()
reject(new Error('Request timeout'))
})
if (options.body) {
req.write(options.body)
}
req.end()
})
}
/**
@@ -947,6 +1070,138 @@ export function validateAirtableId(
return { isValid: true, sanitized: value }
}
/**
* Validates an AWS region identifier
*
* Supported region formats:
* - Standard: us-east-1, eu-west-2, ap-southeast-1, sa-east-1, af-south-1
* - GovCloud: us-gov-east-1, us-gov-west-1
* - China: cn-north-1, cn-northwest-1
* - Israel: il-central-1
* - ISO partitions: us-iso-east-1, us-isob-east-1
*
* @param value - The AWS region to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateAwsRegion(region, 'region')
* if (!result.isValid) {
* return NextResponse.json({ error: result.error }, { status: 400 })
* }
* ```
*/
export function validateAwsRegion(
value: string | null | undefined,
paramName = 'region'
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
// AWS region patterns:
// - Standard: af|ap|ca|eu|me|sa|us|il followed by direction and number
// - GovCloud: us-gov-east-1, us-gov-west-1
// - China: cn-north-1, cn-northwest-1
// - ISO: us-iso-east-1, us-iso-west-1, us-isob-east-1
const awsRegionPattern =
/^(af|ap|ca|cn|eu|il|me|sa|us|us-gov|us-iso|us-isob)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/
if (!awsRegionPattern.test(value)) {
logger.warn('Invalid AWS region format', {
paramName,
value: value.substring(0, 50),
})
return {
isValid: false,
error: `${paramName} must be a valid AWS region (e.g., us-east-1, eu-west-2, us-gov-west-1)`,
}
}
return { isValid: true, sanitized: value }
}
/**
* Validates an S3 bucket name according to AWS naming rules
*
* S3 bucket names must:
* - Be 3-63 characters long
* - Start and end with a letter or number
* - Contain only lowercase letters, numbers, and hyphens
* - Not contain consecutive periods
* - Not be formatted as an IP address
*
* @param value - The S3 bucket name to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateS3BucketName(bucket, 'bucket')
* if (!result.isValid) {
* return NextResponse.json({ error: result.error }, { status: 400 })
* }
* ```
*/
export function validateS3BucketName(
value: string | null | undefined,
paramName = 'bucket'
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
if (value.length < 3 || value.length > 63) {
logger.warn('S3 bucket name length invalid', {
paramName,
length: value.length,
})
return {
isValid: false,
error: `${paramName} must be between 3 and 63 characters`,
}
}
const bucketNamePattern = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$|^[a-z0-9]$/
if (!bucketNamePattern.test(value)) {
logger.warn('Invalid S3 bucket name format', {
paramName,
value: value.substring(0, 63),
})
return {
isValid: false,
error: `${paramName} must start and end with a letter or number, and contain only lowercase letters, numbers, hyphens, and periods`,
}
}
if (value.includes('..')) {
logger.warn('S3 bucket name contains consecutive periods', { paramName })
return {
isValid: false,
error: `${paramName} cannot contain consecutive periods`,
}
}
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/
if (ipPattern.test(value)) {
logger.warn('S3 bucket name formatted as IP address', { paramName })
return {
isValid: false,
error: `${paramName} cannot be formatted as an IP address`,
}
}
return { isValid: true, sanitized: value }
}
/**
* Validates a Google Calendar ID
*

View File

@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest'
import {
isLargeDataKey,
isSensitiveKey,
REDACTED_MARKER,
redactApiKeys,
redactSensitiveValues,
sanitizeEventData,
sanitizeForLogging,
TRUNCATED_MARKER,
} from './redaction'
/**
@@ -18,6 +20,24 @@ describe('REDACTED_MARKER', () => {
})
})
describe('TRUNCATED_MARKER', () => {
it.concurrent('should be the standard marker', () => {
expect(TRUNCATED_MARKER).toBe('[TRUNCATED]')
})
})
describe('isLargeDataKey', () => {
it.concurrent('should identify base64 as large data key', () => {
expect(isLargeDataKey('base64')).toBe(true)
})
it.concurrent('should not identify other keys as large data', () => {
expect(isLargeDataKey('content')).toBe(false)
expect(isLargeDataKey('data')).toBe(false)
expect(isLargeDataKey('base')).toBe(false)
})
})
describe('isSensitiveKey', () => {
describe('exact matches', () => {
it.concurrent('should match apiKey variations', () => {
@@ -234,6 +254,80 @@ describe('redactApiKeys', () => {
expect(result.config.database.password).toBe('[REDACTED]')
expect(result.config.database.host).toBe('localhost')
})
it.concurrent('should truncate base64 fields', () => {
const obj = {
id: 'file-123',
name: 'document.pdf',
base64: 'VGhpcyBpcyBhIHZlcnkgbG9uZyBiYXNlNjQgc3RyaW5n...',
size: 12345,
}
const result = redactApiKeys(obj)
expect(result.id).toBe('file-123')
expect(result.name).toBe('document.pdf')
expect(result.base64).toBe('[TRUNCATED]')
expect(result.size).toBe(12345)
})
it.concurrent('should truncate base64 in nested UserFile objects', () => {
const obj = {
files: [
{
id: 'file-1',
name: 'doc1.pdf',
url: 'http://example.com/file1',
size: 1000,
base64: 'base64content1...',
},
{
id: 'file-2',
name: 'doc2.pdf',
url: 'http://example.com/file2',
size: 2000,
base64: 'base64content2...',
},
],
}
const result = redactApiKeys(obj)
expect(result.files[0].id).toBe('file-1')
expect(result.files[0].base64).toBe('[TRUNCATED]')
expect(result.files[1].base64).toBe('[TRUNCATED]')
})
it.concurrent('should filter UserFile objects to only expose allowed fields', () => {
const obj = {
processedFiles: [
{
id: 'file-123',
name: 'document.pdf',
url: 'http://localhost/api/files/serve/...',
size: 12345,
type: 'application/pdf',
key: 'execution/workspace/workflow/file.pdf',
context: 'execution',
base64: 'VGhpcyBpcyBhIGJhc2U2NCBzdHJpbmc=',
},
],
}
const result = redactApiKeys(obj)
// Exposed fields should be present
expect(result.processedFiles[0].id).toBe('file-123')
expect(result.processedFiles[0].name).toBe('document.pdf')
expect(result.processedFiles[0].url).toBe('http://localhost/api/files/serve/...')
expect(result.processedFiles[0].size).toBe(12345)
expect(result.processedFiles[0].type).toBe('application/pdf')
expect(result.processedFiles[0].base64).toBe('[TRUNCATED]')
// Internal fields should be filtered out
expect(result.processedFiles[0]).not.toHaveProperty('key')
expect(result.processedFiles[0]).not.toHaveProperty('context')
})
})
describe('primitive handling', () => {

View File

@@ -2,10 +2,16 @@
* Centralized redaction utilities for sensitive data
*/
import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file'
export const REDACTED_MARKER = '[REDACTED]'
export const TRUNCATED_MARKER = '[TRUNCATED]'
const BYPASS_REDACTION_KEYS = new Set(['nextPageToken'])
/** Keys that contain large binary/encoded data that should be truncated in logs */
const LARGE_DATA_KEYS = new Set(['base64'])
const SENSITIVE_KEY_PATTERNS: RegExp[] = [
/^api[_-]?key$/i,
/^access[_-]?token$/i,
@@ -88,6 +94,10 @@ export function redactSensitiveValues(value: string): string {
return result
}
export function isLargeDataKey(key: string): boolean {
return LARGE_DATA_KEYS.has(key)
}
export function redactApiKeys(obj: any): any {
if (obj === null || obj === undefined) {
return obj
@@ -101,11 +111,26 @@ export function redactApiKeys(obj: any): any {
return obj.map((item) => redactApiKeys(item))
}
if (isUserFile(obj)) {
const filtered = filterUserFileForDisplay(obj)
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(filtered)) {
if (isLargeDataKey(key) && typeof value === 'string') {
result[key] = TRUNCATED_MARKER
} else {
result[key] = value
}
}
return result
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
if (isSensitiveKey(key)) {
result[key] = REDACTED_MARKER
} else if (isLargeDataKey(key) && typeof value === 'string') {
result[key] = TRUNCATED_MARKER
} else if (typeof value === 'object' && value !== null) {
result[key] = redactApiKeys(value)
} else {

View File

@@ -1,3 +1,5 @@
import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file'
const MAX_STRING_LENGTH = 15000
const MAX_DEPTH = 50
@@ -8,32 +10,9 @@ function truncateString(value: string, maxLength = MAX_STRING_LENGTH): string {
return `${value.substring(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
}
export function isUserFile(candidate: unknown): candidate is {
id: string
name: string
url: string
key: string
size: number
type: string
context?: string
} {
if (!candidate || typeof candidate !== 'object') {
return false
}
const value = candidate as Record<string, unknown>
return (
typeof value.id === 'string' &&
typeof value.key === 'string' &&
typeof value.url === 'string' &&
typeof value.name === 'string'
)
}
function filterUserFile(data: any): any {
if (isUserFile(data)) {
const { id, name, url, size, type } = data
return { id, name, url, size, type }
return filterUserFileForDisplay(data)
}
return data
}

View File

@@ -0,0 +1,57 @@
import type { UserFile } from '@/executor/types'
export type UserFileLike = Pick<UserFile, 'id' | 'name' | 'url' | 'key'> &
Partial<Pick<UserFile, 'size' | 'type' | 'context' | 'base64'>>
/**
* Fields exposed for UserFile objects in UI (tag dropdown) and logs.
* Internal fields like 'key' and 'context' are not exposed.
*/
export const USER_FILE_DISPLAY_FIELDS = ['id', 'name', 'url', 'size', 'type', 'base64'] as const
export type UserFileDisplayField = (typeof USER_FILE_DISPLAY_FIELDS)[number]
/**
* Checks if a value matches the minimal UserFile shape.
*/
export function isUserFile(value: unknown): value is UserFileLike {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Record<string, unknown>
return (
typeof candidate.id === 'string' &&
typeof candidate.key === 'string' &&
typeof candidate.url === 'string' &&
typeof candidate.name === 'string'
)
}
/**
* Checks if a value matches the full UserFile metadata shape.
*/
export function isUserFileWithMetadata(value: unknown): value is UserFile {
if (!isUserFile(value)) {
return false
}
const candidate = value as Record<string, unknown>
return typeof candidate.size === 'number' && typeof candidate.type === 'string'
}
/**
* Filters a UserFile object to only include display fields.
* Used for both UI display and log sanitization.
*/
export function filterUserFileForDisplay(data: Record<string, unknown>): Record<string, unknown> {
const filtered: Record<string, unknown> = {}
for (const field of USER_FILE_DISPLAY_FIELDS) {
if (field in data) {
filtered[field] = data[field]
}
}
return filtered
}

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { isUserFile } from '@/lib/core/utils/display-filters'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import type { ExecutionContext } from '@/lib/uploads/contexts/execution/utils'
import { generateExecutionFileKey, generateFileId } from '@/lib/uploads/contexts/execution/utils'
import type { UserFile } from '@/executor/types'
@@ -169,7 +169,7 @@ export async function uploadFileFromRawData(
context: ExecutionContext,
userId?: string
): Promise<UserFile> {
if (isUserFile(rawData)) {
if (isUserFileWithMetadata(rawData)) {
return rawData
}

View File

@@ -455,3 +455,27 @@ export async function generatePresignedDownloadUrl(
export function hasCloudStorage(): boolean {
return USE_BLOB_STORAGE || USE_S3_STORAGE
}
/**
* Get S3 bucket and key information for a storage key
* Useful for services that need direct S3 access (e.g., AWS Textract async)
*/
export function getS3InfoForKey(
key: string,
context: StorageContext
): { bucket: string; key: string } {
if (!USE_S3_STORAGE) {
throw new Error('S3 storage is not configured. Cannot retrieve S3 info for key.')
}
const config = getStorageConfig(context)
if (!config.bucket) {
throw new Error(`S3 bucket not configured for context: ${context}`)
}
return {
bucket: config.bucket,
key,
}
}

View File

@@ -1,6 +1,7 @@
'use server'
import type { Logger } from '@sim/logger'
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import type { StorageContext } from '@/lib/uploads'
import { isExecutionFile } from '@/lib/uploads/contexts/execution/utils'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
@@ -9,38 +10,32 @@ import type { UserFile } from '@/executor/types'
/**
* Download a file from a URL (internal or external)
* For internal URLs, uses direct storage access (server-side only)
* For external URLs, uses HTTP fetch
* For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning
*/
export async function downloadFileFromUrl(fileUrl: string, timeoutMs = 180000): Promise<Buffer> {
const { isInternalFileUrl } = await import('./file-utils')
const { parseInternalFileUrl } = await import('./file-utils')
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
try {
if (isInternalFileUrl(fileUrl)) {
const { key, context } = parseInternalFileUrl(fileUrl)
const { downloadFile } = await import('@/lib/uploads/core/storage-service')
const buffer = await downloadFile({ key, context })
clearTimeout(timeoutId)
return buffer
}
const response = await fetch(fileUrl, { signal: controller.signal })
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`)
}
return Buffer.from(await response.arrayBuffer())
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('File download timed out')
}
throw error
if (isInternalFileUrl(fileUrl)) {
const { key, context } = parseInternalFileUrl(fileUrl)
const { downloadFile } = await import('@/lib/uploads/core/storage-service')
return downloadFile({ key, context })
}
const urlValidation = await validateUrlWithDNS(fileUrl, 'fileUrl')
if (!urlValidation.isValid) {
throw new Error(`Invalid file URL: ${urlValidation.error}`)
}
const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, {
timeout: timeoutMs,
})
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`)
}
return Buffer.from(await response.arrayBuffer())
}
/**

View File

@@ -0,0 +1,319 @@
import type { Logger } from '@sim/logger'
import { createLogger } from '@sim/logger'
import { getRedisClient } from '@/lib/core/config/redis'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { bufferToBase64 } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage, downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
import type { UserFile } from '@/executor/types'
const DEFAULT_MAX_BASE64_BYTES = 10 * 1024 * 1024
const DEFAULT_TIMEOUT_MS = 180000
const DEFAULT_CACHE_TTL_SECONDS = 300
const REDIS_KEY_PREFIX = 'user-file:base64:'
interface Base64Cache {
get(file: UserFile): Promise<string | null>
set(file: UserFile, value: string, ttlSeconds: number): Promise<void>
}
interface HydrationState {
seen: WeakSet<object>
cache: Base64Cache
cacheTtlSeconds: number
}
export interface Base64HydrationOptions {
requestId?: string
executionId?: string
logger?: Logger
maxBytes?: number
allowUnknownSize?: boolean
timeoutMs?: number
cacheTtlSeconds?: number
}
class InMemoryBase64Cache implements Base64Cache {
private entries = new Map<string, { value: string; expiresAt: number }>()
async get(file: UserFile): Promise<string | null> {
const key = getFileCacheKey(file)
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return null
}
return entry.value
}
async set(file: UserFile, value: string, ttlSeconds: number): Promise<void> {
const key = getFileCacheKey(file)
const expiresAt = Date.now() + ttlSeconds * 1000
this.entries.set(key, { value, expiresAt })
}
}
function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Base64Cache {
const redis = getRedisClient()
const { executionId } = options
if (!redis) {
logger.warn(
`[${options.requestId}] Redis unavailable for base64 cache, using in-memory fallback`
)
return new InMemoryBase64Cache()
}
return {
async get(file: UserFile) {
try {
const key = getFullCacheKey(executionId, file)
return await redis.get(key)
} catch (error) {
logger.warn(`[${options.requestId}] Redis get failed, skipping cache`, error)
return null
}
},
async set(file: UserFile, value: string, ttlSeconds: number) {
try {
const key = getFullCacheKey(executionId, file)
await redis.set(key, value, 'EX', ttlSeconds)
} catch (error) {
logger.warn(`[${options.requestId}] Redis set failed, skipping cache`, error)
}
},
}
}
function createHydrationState(options: Base64HydrationOptions, logger: Logger): HydrationState {
return {
seen: new WeakSet<object>(),
cache: createBase64Cache(options, logger),
cacheTtlSeconds: options.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS,
}
}
function getHydrationLogger(options: Base64HydrationOptions): Logger {
return options.logger ?? createLogger('UserFileBase64')
}
function getFileCacheKey(file: UserFile): string {
if (file.key) {
return `key:${file.key}`
}
if (file.url) {
return `url:${file.url}`
}
return `id:${file.id}`
}
function getFullCacheKey(executionId: string | undefined, file: UserFile): string {
const fileKey = getFileCacheKey(file)
if (executionId) {
return `${REDIS_KEY_PREFIX}exec:${executionId}:${fileKey}`
}
return `${REDIS_KEY_PREFIX}${fileKey}`
}
async function resolveBase64(
file: UserFile,
options: Base64HydrationOptions,
logger: Logger
): Promise<string | null> {
if (file.base64) {
return file.base64
}
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES
const allowUnknownSize = options.allowUnknownSize ?? false
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const hasStableStorageKey = Boolean(file.key)
if (Number.isFinite(file.size) && file.size > maxBytes) {
logger.warn(
`[${options.requestId}] Skipping base64 for ${file.name} (size ${file.size} exceeds ${maxBytes})`
)
return null
}
if (
(!Number.isFinite(file.size) || file.size <= 0) &&
!allowUnknownSize &&
!hasStableStorageKey
) {
logger.warn(`[${options.requestId}] Skipping base64 for ${file.name} (unknown file size)`)
return null
}
let buffer: Buffer | null = null
const requestId = options.requestId ?? 'unknown'
if (file.key) {
try {
buffer = await downloadFileFromStorage(file, requestId, logger)
} catch (error) {
logger.warn(
`[${requestId}] Failed to download ${file.name} from storage, trying URL fallback`,
error
)
}
}
if (!buffer && file.url) {
try {
buffer = await downloadFileFromUrl(file.url, timeoutMs)
} catch (error) {
logger.warn(`[${requestId}] Failed to download ${file.name} from URL`, error)
}
}
if (!buffer) {
return null
}
if (buffer.length > maxBytes) {
logger.warn(
`[${options.requestId}] Skipping base64 for ${file.name} (downloaded ${buffer.length} exceeds ${maxBytes})`
)
return null
}
return bufferToBase64(buffer)
}
async function hydrateUserFile(
file: UserFile,
options: Base64HydrationOptions,
state: HydrationState,
logger: Logger
): Promise<UserFile> {
const cached = await state.cache.get(file)
if (cached) {
return { ...file, base64: cached }
}
const base64 = await resolveBase64(file, options, logger)
if (!base64) {
return file
}
await state.cache.set(file, base64, state.cacheTtlSeconds)
return { ...file, base64 }
}
async function hydrateValue(
value: unknown,
options: Base64HydrationOptions,
state: HydrationState,
logger: Logger
): Promise<unknown> {
if (!value || typeof value !== 'object') {
return value
}
if (isUserFileWithMetadata(value)) {
return hydrateUserFile(value, options, state, logger)
}
if (state.seen.has(value)) {
return value
}
state.seen.add(value)
if (Array.isArray(value)) {
const hydratedItems = await Promise.all(
value.map((item) => hydrateValue(item, options, state, logger))
)
return hydratedItems
}
const entries = await Promise.all(
Object.entries(value).map(async ([key, entryValue]) => {
const hydratedEntry = await hydrateValue(entryValue, options, state, logger)
return [key, hydratedEntry] as const
})
)
return Object.fromEntries(entries)
}
/**
* Hydrates UserFile objects within a value to include base64 content.
* Returns the original structure with UserFile.base64 set where available.
*/
export async function hydrateUserFilesWithBase64(
value: unknown,
options: Base64HydrationOptions
): Promise<unknown> {
const logger = getHydrationLogger(options)
const state = createHydrationState(options, logger)
return hydrateValue(value, options, state, logger)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object') {
return false
}
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/**
* Checks if a value contains any UserFile objects with metadata.
*/
export function containsUserFileWithMetadata(value: unknown): boolean {
if (!value || typeof value !== 'object') {
return false
}
if (isUserFileWithMetadata(value)) {
return true
}
if (Array.isArray(value)) {
return value.some((item) => containsUserFileWithMetadata(item))
}
if (!isPlainObject(value)) {
return false
}
return Object.values(value).some((entry) => containsUserFileWithMetadata(entry))
}
/**
* Cleans up base64 cache entries for a specific execution.
* Should be called at the end of workflow execution.
*/
export async function cleanupExecutionBase64Cache(executionId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) {
return
}
const pattern = `${REDIS_KEY_PREFIX}exec:${executionId}:*`
const logger = createLogger('UserFileBase64')
try {
let cursor = '0'
let deletedCount = 0
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100)
cursor = nextCursor
if (keys.length > 0) {
await redis.del(...keys)
deletedCount += keys.length
}
} while (cursor !== '0')
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} base64 cache entries for execution ${executionId}`)
}
} catch (error) {
logger.warn(`Failed to cleanup base64 cache for execution ${executionId}`, error)
}
}

View File

@@ -5,7 +5,7 @@ import { and, eq, isNull, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import Parser from 'rss-parser'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
@@ -268,15 +268,12 @@ async function fetchNewRssItems(
throw new Error(`Invalid RSS feed URL: ${urlValidation.error}`)
}
const pinnedUrl = createPinnedUrl(config.feedUrl, urlValidation.resolvedIP!)
const response = await fetch(pinnedUrl, {
const response = await secureFetchWithPinnedIP(config.feedUrl, urlValidation.resolvedIP!, {
headers: {
Host: urlValidation.originalHostname!,
'User-Agent': 'Sim/1.0 RSS Poller',
Accept: 'application/rss+xml, application/xml, text/xml, */*',
},
signal: AbortSignal.timeout(30000),
timeout: 30000,
})
if (!response.ok) {

View File

@@ -3,7 +3,11 @@ import { account, webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import {
type SecureFetchResponse,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation'
import type { DbOrTx } from '@/lib/db/types'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -98,7 +102,7 @@ async function fetchWithDNSPinning(
url: string,
accessToken: string,
requestId: string
): Promise<Response | null> {
): Promise<SecureFetchResponse | null> {
try {
const urlValidation = await validateUrlWithDNS(url, 'contentUrl')
if (!urlValidation.isValid) {
@@ -108,19 +112,14 @@ async function fetchWithDNSPinning(
return null
}
const pinnedUrl = createPinnedUrl(url, urlValidation.resolvedIP!)
const headers: Record<string, string> = {
Host: urlValidation.originalHostname!,
}
const headers: Record<string, string> = {}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const response = await fetch(pinnedUrl, {
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
headers,
redirect: 'follow',
})
return response

View File

@@ -351,7 +351,7 @@ function collectOutputPaths(
if (value && typeof value === 'object' && 'type' in value) {
const typedValue = value as { type: unknown }
if (typedValue.type === 'files') {
if (typedValue.type === 'files' || typedValue.type === 'file[]') {
paths.push(...expandFileTypeProperties(path))
} else {
paths.push(path)
@@ -393,7 +393,8 @@ function getFilePropertyType(outputs: OutputDefinition, pathParts: string[]): st
current &&
typeof current === 'object' &&
'type' in current &&
(current as { type: unknown }).type === 'files'
((current as { type: unknown }).type === 'files' ||
(current as { type: unknown }).type === 'file[]')
) {
return USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES]
}
@@ -462,6 +463,11 @@ function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'files' || value.type === 'file[]') {
paths.push(...expandFileTypeProperties(currentPath))
continue
}
const hasNestedProperties =
((value.type === 'object' || value.type === 'json') && value.properties) ||
(value.type === 'array' && value.items?.properties) ||
@@ -518,6 +524,17 @@ function generateOutputPathsWithTypes(
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'files' || value.type === 'file[]') {
paths.push({ path: currentPath, type: value.type })
for (const prop of USER_FILE_ACCESSIBLE_PROPERTIES) {
paths.push({
path: `${currentPath}.${prop}`,
type: USER_FILE_PROPERTY_TYPES[prop as keyof typeof USER_FILE_PROPERTY_TYPES],
})
}
continue
}
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
@@ -545,14 +562,26 @@ function generateOutputPathsWithTypes(
* Gets the tool outputs for a block operation.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param subBlocks - SubBlock values to pass to the tool selector
* @returns Outputs schema for the tool, or empty object on error
*/
export function getToolOutputs(blockConfig: BlockConfig, operation: string): Record<string, any> {
export function getToolOutputs(
blockConfig: BlockConfig,
subBlocks?: Record<string, SubBlockWithValue>
): Record<string, any> {
if (!blockConfig?.tools?.config?.tool) return {}
try {
const toolId = blockConfig.tools.config.tool({ operation })
// Build params object from subBlock values for tool selector
// This allows tool selectors to use any field (operation, provider, etc.)
const params: Record<string, any> = {}
if (subBlocks) {
for (const [key, subBlock] of Object.entries(subBlocks)) {
params[key] = subBlock.value
}
}
const toolId = blockConfig.tools.config.tool(params)
if (!toolId) return {}
const toolConfig = getTool(toolId)
@@ -560,7 +589,7 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec
return toolConfig.outputs
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
logger.warn('Failed to get tool outputs', { error })
return {}
}
}
@@ -569,16 +598,14 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param subBlocks - Optional subBlock values for condition evaluation
* @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(
blockConfig: BlockConfig,
operation: string,
subBlocks?: Record<string, SubBlockWithValue>
): string[] {
const outputs = getToolOutputs(blockConfig, operation)
const outputs = getToolOutputs(blockConfig, subBlocks)
if (!outputs || Object.keys(outputs).length === 0) return []
@@ -613,16 +640,16 @@ export function getOutputPathsFromSchema(outputs: Record<string, any>): string[]
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param subBlocks - SubBlock values for tool selection
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
export function getToolOutputType(
blockConfig: BlockConfig,
operation: string,
subBlocks: Record<string, SubBlockWithValue> | undefined,
path: string
): string {
const outputs = getToolOutputs(blockConfig, operation)
const outputs = getToolOutputs(blockConfig, subBlocks)
if (!outputs || Object.keys(outputs).length === 0) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(outputs)

Some files were not shown because too many files have changed in this diff Show More