Compare commits

...

59 Commits

Author SHA1 Message Date
Waleed
cbfab1ceaa v0.6.36: new chunkers, sockets state machine, google sheets/drive/calendar triggers, docs updates, integrations/models pages improvements 2026-04-10 21:58:16 -07:00
Waleed
1acafe8763 feat(knowledge): add token, sentence, recursive, and regex chunkers (#4102)
* feat(knowledge): add token, sentence, recursive, and regex chunkers

* fix(chunkers): standardize token estimation and use emcn dropdown

- Refactor all existing chunkers (Text, JsonYaml, StructuredData, Docs) to use shared utils
- Fix inconsistent token estimation (JsonYaml used tiktoken, StructuredData used /3 ratio)
- Fix DocsChunker operator precedence bug and hard-coded 300-token limit
- Fix JsonYamlChunker isStructuredData false positive on plain strings
- Add MAX_DEPTH recursion guard to JsonYamlChunker
- Replace @/components/ui/select with emcn DropdownMenu in strategy selector

* fix(chunkers): address research audit findings

- Expand RecursiveChunker recipes: markdown adds horizontal rules, code
  fences, blockquotes; code adds const/let/var/if/for/while/switch/return
- RecursiveChunker fallback uses splitAtWordBoundaries instead of char slicing
- RegexChunker ReDoS test uses adversarial strings (repeated chars, spaces)
- SentenceChunker abbreviation list adds St/Rev/Gen/No/Fig/Vol/months
  and single-capital-letter lookbehind
- Add overlap < maxSize validation in Zod schema and UI form
- Add pattern max length (500) validation in Zod schema
- Fix StructuredDataChunker footer grammar

* fix(chunkers): fix remaining audit issues across all chunkers

- DocsChunker: extract headers from cleaned content (not raw markdown)
  to fix position mismatch between header positions and chunk positions
- DocsChunker: strip export statements and JSX expressions in cleanContent
- DocsChunker: fix table merge dedup using equality instead of includes
- JsonYamlChunker: preserve path breadcrumbs when nested value fits in
  one chunk, matching LangChain RecursiveJsonSplitter behavior
- StructuredDataChunker: detect 2-column CSV (lowered threshold from >2
  to >=1) and use 20% relative tolerance instead of absolute +/-2
- TokenChunker: use sliding window overlap (matching LangChain/Chonkie)
  where chunks stay within chunkSize instead of exceeding it
- utils: splitAtWordBoundaries accepts optional stepChars for sliding
  window overlap; addOverlap uses newline join instead of space

* chore(chunkers): lint formatting

* updated styling

* fix(chunkers): audit fixes and comprehensive tests

- Fix SentenceChunker regex: lookbehinds now include the period to correctly handle abbreviations (Mr., Dr., etc.), initials (J.K.), and decimals
- Fix RegexChunker ReDoS: reset lastIndex between adversarial test iterations, add poisoned-suffix test strings
- Fix DocsChunker: skip code blocks during table boundary detection to prevent false positives from pipe characters
- Fix JsonYamlChunker: oversized primitive leaf values now fall back to text chunking instead of emitting a single chunk
- Fix TokenChunker: pass 0 to buildChunks for overlap metadata since sliding window handles overlap inherently
- Add defensive guard in splitAtWordBoundaries to prevent infinite loops if step is 0
- Add tests for utils, TokenChunker, SentenceChunker, RecursiveChunker, RegexChunker (236 total tests, 0 failures)
- Fix existing test expectations for updated footer format and isStructuredData behavior

* chore(chunkers): remove unnecessary comments and dead code

Strip 445 lines of redundant TSDoc, math calculation comments,
implementation rationale notes, and assertion-restating comments
across all chunker source and test files.

* fix(chunkers): address PR review comments

- Fix regex fallback path: use sliding window for overlap instead of
  passing chunkOverlap to buildChunks without prepended overlap text
- Fix misleading strategy label: "Text (hierarchical splitting)" →
  "Text (word boundary splitting)"

* fix(chunkers): use consistent overlap pattern in regex fallback

Use addOverlap + buildChunks(chunks, overlap) in the regex fallback
path to match the main path and all other chunkers (TextChunker,
RecursiveChunker). The sliding window approach was inconsistent.

* fix(chunkers): prevent content loss in word boundary splitting

When splitAtWordBoundaries snaps end back to a word boundary, advance
pos from end (not pos + step) in non-overlapping mode. The step-based
advancement is preserved for the sliding window case (TokenChunker).

* fix(chunkers): restore structured data token ratio and overlap joiner

- Restore /3 token estimation for StructuredDataChunker (structured data
  is denser than prose, ~3 chars/token vs ~4)
- Change addOverlap joiner from \n to space to match original TextChunker
  behavior

* lint

* fix(chunkers): fall back to character-level overlap in sentence chunker

When no complete sentence fits within the overlap budget,
fall back to character-level word-boundary overlap from the
previous group's text. This ensures buildChunks metadata is
always correct.

* fix(chunkers): fix log message and add missing month abbreviations

- Fix regex fallback log: "character splitting" → "word-boundary splitting"
- Add Jun and Jul to sentence chunker abbreviation list

* lint

* fix(chunkers): restore structured data detection threshold to > 2

avgCount >= 1 was too permissive — prose with consistent comma usage
would be misclassified as CSV. Restore original > 2 threshold while
keeping the improved proportional tolerance.

* fix(chunkers): pass chunkOverlap to buildChunks in TokenChunker

* fix(chunkers): restore separator-as-joiner pattern in splitRecursively

Separator was unconditionally prepended to parts after the first,
leaving leading punctuation on chunks after a boundary reset.

* feat(knowledge): add JSONL file support for knowledge base uploads

Parses JSON Lines files by splitting on newlines and converting to a
JSON array, which then flows through the existing JsonYamlChunker.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:33:29 -07:00
Emir Karabeg
c1d788ce94 improvement(integrations, models): ui/ux (#4105)
* improvement(integrations, models): ui/ux

* fix(models, integrations): dedup ChevronArrow/provider colors, fix UTC date rendering

- Extract PROVIDER_COLORS and getProviderColor to model-colors.ts to eliminate
  identical definitions in model-comparison-charts and model-timeline-chart
- Remove duplicate private ChevronArrow from integration-card; import the
  exported one from model-primitives instead
- Add timeZone: 'UTC' to formatShortDate so ISO date-only strings (parsed as
  UTC midnight) render the correct calendar day in all timezones

* refactor(models): rename model-colors.ts to consts.ts

* improvement(models): derive provider colors/resellers from definitions, reorient FAQs to agent builder

Dynamic data:
- Add `color` and `isReseller` fields to ProviderDefinition interface
- Move brand colors for all 10 providers into their definitions
- Mark 6 reseller providers (Azure, Bedrock, Vertex, OpenRouter, Fireworks)
- consts.ts now derives color map from MODEL_CATALOG_PROVIDERS
- model-comparison-charts derives RESELLER_PROVIDERS from catalog
- Fix deepseek name: Deepseek → DeepSeek; remove now-redundant
  PROVIDER_NAME_OVERRIDES and getProviderDisplayName from utils
- Add color/isReseller fields to CatalogProvider; clean up duplicate
  providerDisplayName in searchText array

FAQs:
- Replace all 4 main-page FAQs with 5 agent-builder-oriented ones
  covering model selection, context windows, pricing, tool use, and
  how to use models in a Sim agent workflow
- buildProviderFaqs: add conditional tool use FAQ per provider
- buildModelFaqs: add bestFor FAQ (conditional on field presence);
  improve context window answer to explain agent implications;
  tighten capabilities answer wording

* chore(models): remove model-colors.ts (superseded by consts.ts)

* update footer

---------

Co-authored-by: waleed <walif6@gmail.com>
2026-04-10 20:46:44 -07:00
Vikhyath Mondreti
bad78ccb59 improvement(sockets): workflow switching state machine (#4104)
* improvement(sockets): workflow switching state machine

* address comments
2026-04-10 19:06:10 -07:00
Waleed
8bbca9ba05 fix(trigger): fix polling trigger config defaults, row count, clock-skew, and stale config clearing (#4101)
* fix(trigger): fix polling trigger config defaults, row count, clock-skew, and stale config clearing

* fix(deploy): track first-pass fills to prevent stale baseConfig bypassing required-field validation

Use a dedicated `filledSubBlockIds` Set populated during the first pass so the second-pass skip guard is based solely on live `getConfigValue` results, not on stale entries spread from `baseConfig` (`triggerConfig`).

* fix(trigger): prevent calendar cursor regression when all events are filtered client-side
2026-04-10 17:41:36 -07:00
Theodore Li
34f77e00bc update(doc): Update hosted key/byok section (#4098)
* fix(doc): Update byok docs section

* Update cost page with new byok providers

* Add translated sections

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-10 17:48:40 -04:00
Waleed
fb5ebd3bed fix(ui): support Tab key to select items in tag, env-var, and resource dropdowns (#4096)
* fix(ui): support Tab key to select items in tag, env-var, and resource dropdowns

* fix(ui): support Tab key to select items in tag, env-var, and resource dropdowns

* fix(ui): guard Tab selection against Shift+Tab and undefined index
2026-04-10 14:30:09 -07:00
Waleed
2e85361ed6 fix(tools): use OAuth-compatible URL for JSM Forms API (#4099)
The Forms API has a different base URL for OAuth vs Basic Auth.
Per Atlassian support, OAuth requires the /ex/jira/{cloudId}/forms
pattern, not /jira/forms/cloud/{cloudId} which only works with
Basic Auth. This was causing 401 Unauthorized errors.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 14:28:29 -07:00
Waleed
59de6bbb43 fix(trigger): show selector display names on canvas for trigger file/sheet selectors (#4097)
* fix(trigger): show selector display names on canvas for trigger file/sheet selectors

* fix(trigger): use isNonEmptyValue in canonical member scan to match visibility contract
2026-04-10 14:24:44 -07:00
Waleed
2b9fb19899 fix(trigger): resolve dependsOn for trigger-mode subblocks sharing canonical groups with block subblocks (#4095) 2026-04-10 12:50:04 -07:00
Theodore Li
266bc2141d feat(ui): allow multiselect in resource tabs (#4094)
* feat(ui): allow multiselect in resource tabs

* Fix bugs with deselection

* Try catch resource tab deletion independently

* Fix chat switch selection

* Default to null active id

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-10 15:20:01 -04:00
Waleed
6099683e5a feat(trigger): add Google Sheets, Drive, and Calendar polling triggers (#4081)
* feat(trigger): add Google Sheets, Drive, and Calendar polling triggers

Add polling triggers for Google Sheets (new rows), Google Drive (file
changes via changes.list API), and Google Calendar (event updates via
updatedMin). Each includes OAuth credential support, configurable
filters (event type, MIME type, folder, search term, render options),
idempotency, and first-poll seeding. Wire triggers into block configs
and regenerate integrations.json. Update add-trigger skill with polling
instructions and versioned block wiring guidance.

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

* fix(polling): address PR review feedback for Google polling triggers

- Fix Drive cursor stall: use nextPageToken as resume point when
  breaking early from pagination instead of re-using the original token
- Eliminate redundant Drive API call in Sheets poller by returning
  modifiedTime from the pre-check function
- Add 403/429 rate-limit handling to Sheets API calls matching the
  Calendar handler pattern
- Remove unused changeType field from DriveChangeEntry interface
- Rename triggers/google_drive to triggers/google-drive for consistency

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

* fix(polling): fix Drive pre-check never activating in Sheets poller

isDriveFileUnchanged short-circuited when lastModifiedTime was
undefined, never calling the Drive API — so currentModifiedTime
was never populated, creating a permanent chicken-and-egg loop.
Now always calls the Drive API and returns the modifiedTime
regardless of whether there's a previous value to compare against.

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

* chore(lint): fix import ordering in triggers registry

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

* fix(polling): address PR review feedback for Google polling handlers

- Fix fetchHeaderRow to throw on 403/429 rate limits instead of silently
  returning empty headers (prevents rows from being processed without
  headers and lastKnownRowCount from advancing past them permanently)
- Fix Drive pagination to avoid advancing resume cursor past sliced
  changes (prevents permanent change loss when allChanges > maxFiles)
- Remove unused logger import from Google Drive trigger config

* fix(polling): prevent data loss on partial row failures and harden idempotency key

- Sheets: only advance lastKnownRowCount by processedCount when there
  are failures, so failed rows are retried on the next poll cycle
  (idempotency deduplicates already-processed rows on re-fetch)
- Drive: add fallback for change.time in idempotency key to prevent
  key collisions if the field is ever absent from the API response

* fix(polling): remove unused variable and preserve lastModifiedTime on Drive API failure

- Remove unused `now` variable from Google Drive polling handler
- Preserve stored lastModifiedTime when Drive API pre-check fails
  (previously wrote undefined, disabling the optimization until the
  next successful Drive API call)

* fix(polling): don't advance state when all events fail across sheets, calendar, drive handlers

* fix(polling): retry failed idempotency keys, fix drive cursor overshoot, fix calendar inclusive updatedMin

* fix(polling): revert calendar timestamp on any failure, not just all-fail

* fix(polling): revert drive cursor on any failure, not just all-fail

* feat(triggers): add canonical selector toggle to google polling triggers

- Add 'trigger-advanced' mode to SubBlockConfig so canonical pairs work in trigger mode
- Fix buildCanonicalIndex: trigger-mode subblocks don't overwrite non-trigger basicId, deduplicate advancedIds from block spreads
- Update editor, subblock layout, and trigger config aggregation to include trigger-advanced subblocks
- Replace dropdown+fetchOptions in Calendar/Sheets/Drive pollers with file-selector (basic) + short-input (advanced) canonical pairs
- Add canonicalParamId: 'oauthCredential' to triggerCredentials for selector context resolution
- Update polling handlers to read canonical fallbacks (calendarId||manualCalendarId, etc.)

* test(blocks): handle trigger-advanced mode in canonical validation tests

* fix(triggers): handle trigger-advanced mode in deploy, preview, params, and copilot

* fix(polling): use position-only idempotency key for sheets rows

* fix(polling): don't advance calendar timestamp to client clock on empty poll

* fix(polling): remove extraneous comment from calendar poller

* fix(polling): drive cursor stall on full page, calendar latestUpdated past filtered events

* fix(polling): advance calendar cursor past fully-filtered event batches

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 23:43:28 -07:00
Waleed
4f40c4ce3e v0.6.35: additional jira fields, HITL docs, logs cleanup efficiency 2026-04-09 22:53:05 -07:00
Waleed
3efbd1d612 fix(agent): include model in structured response output (#4092)
* fix(agent): include model in structured response output

* fix(agent): update test expectation for model in structured response
2026-04-09 22:50:26 -07:00
Waleed
04c1f8e475 feat(tools): add fields parameter to Jira search block (#4091)
* feat(tools): add fields parameter to Jira search block

Expose the Jira REST API `fields` parameter on the search operation,
allowing users to specify which fields to return per issue. This reduces
response payload size by 10-15x, preventing 10MB workflow state limit
errors for users with high ticket volume.

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

* style(tools): remove redundant type annotation in fields map callback

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

* fix(tools): restore type annotation for implicit any in params callback

The params object is untyped, so TypeScript cannot infer the string
element type from .split() — the explicit annotation is required.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:45:18 -07:00
Waleed
476669fd55 docs(openapi): add Human in the Loop section to API reference sidebar (#4089)
Add the generated human-in-the-loop group to the docs navigation
and create meta.json listing all HITL operation IDs so endpoints
render in the API reference.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 18:53:46 -07:00
Theodore Li
4074109362 fix(log): log cleanup sql query (#4087)
* fix(log): log cleanup sql query

* perf(log): use startedAt index for cleanup query filter

Switch cleanup WHERE clause from createdAt to startedAt to leverage
the existing composite index (workspaceId, startedAt), converting a
full table scan to an index range scan. Also remove explanatory comment.

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

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 18:04:15 -07:00
Waleed
171485d3b6 fix(tools): handle all Atlassian error formats in parseJsmErrorMessage (#4088)
Update parseJsmErrorMessage to extract errors from all Atlassian API
response formats: errorMessage (JSM), errorMessages array (Jira),
errors[].title RFC 7807 (Confluence/Forms), field-level errors object,
and message (gateway). Remove redundant prefix wrapping so the raw
error message surfaces cleanly through the extractor.
2026-04-09 17:08:19 -07:00
Waleed
d33acf426d v0.6.34: trigger.dev fixes, CI speedup, atlassian error extractor 2026-04-09 15:31:13 -07:00
Waleed
bce638dd75 fix(tools): add Atlassian error extractor to all Jira, JSM, and Confluence tools (#4085)
* fix(tools): add Atlassian error extractor to all Jira, JSM, and Confluence tools

Wire up the existing `atlassian-errors` error extractor to all 95 Atlassian
tool configs so the executor surfaces meaningful error messages instead of
generic status codes. Also fix the extractor itself to handle all three
Atlassian error response formats: `errorMessage` (JSM), `errorMessages`
array (Jira), and `message` (Confluence).

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

* chore(tools): lint formatting fix for error extractor

* fix(tools): handle all Atlassian error formats in error extractor

Add RFC 7807 errors[].title format (Confluence v2, Forms/ProForma API)
and Jira field-level errors object to the atlassian-errors extractor.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:18:34 -07:00
Waleed
05b5588a7b improvement(ci): parallelize Docker builds and fix test timeouts (#4083)
* improvement(ci): parallelize Docker builds with tests and remove duplicate turbo install

* fix(test): use SecureFetchResponse shape in mock instead of standard Response
2026-04-09 15:18:19 -07:00
Waleed
32bdf3cfa5 fix(trigger): use @react-email/render v2 to fix renderToPipeableStream error (#4084) 2026-04-09 14:46:57 -07:00
Waleed
12deb0f5b4 chore(ci): bump actions/checkout to v6 and dorny/paths-filter to v4 (#4082)
* chore(ci): bump actions/checkout to v6 and dorny/paths-filter to v4

* fix(ci): mock secureFetchWithPinnedIP in tools tests to prevent timeouts

* lint
2026-04-09 14:33:11 -07:00
Waleed
3c8bb4076c v0.6.33: polling improvements, jsm forms tools, credentials reactquery invalidation, HITL docs 2026-04-09 14:03:38 -07:00
Waleed
c393791f04 docs(openapi): add Human in the Loop API endpoints (#4079)
* docs(openapi): add Human in the Loop API endpoints

Add HITL pause/resume endpoints to the OpenAPI spec covering
the full workflow pause lifecycle: listing paused executions,
inspecting pause details, and resuming with input.

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

* docs(openapi): add 403 and 500 responses to HITL endpoints

Address PR review feedback: add missing 403 Forbidden response
to all HITL endpoints (from validateWorkflowAccess), and 500
responses to resume endpoints that have explicit error paths.

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

* lint

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 14:01:09 -07:00
Waleed
fc3e762b1f feat(trigger): add ServiceNow webhook triggers (#4077)
* feat(trigger): add ServiceNow webhook triggers

* fix(trigger): add webhook secret field and remove non-TSDoc comment

Add webhookSecret field to ServiceNow triggers (matching Salesforce pattern)
so users are prompted to protect the webhook endpoint. Update setup
instructions to include Authorization header in the Business Rule example.
Remove non-TSDoc inline comment in the block config.

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

* feat(trigger): add ServiceNow provider handler with event matching

Add dedicated ServiceNow webhook provider handler with:
- verifyAuth: validates webhookSecret via Bearer token or X-Sim-Webhook-Secret
- matchEvent: filters events by trigger type and table name using
  isServiceNowEventMatch utility (matching Salesforce/GitHub pattern)

The event matcher handles incident created/updated and change request
created/updated triggers with table name enforcement and event type
normalization. The generic webhook trigger passes through all events
but still respects the optional table name filter.

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

* lint

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 13:59:07 -07:00
Waleed
70f04c003b feat(jsm): add ProForma/JSM Forms discovery tools (#4078)
* feat(jsm): add ProForma/JSM Forms discovery tools

Add three new tools for discovering and inspecting JSM Forms (ProForma) templates
and their structure, enabling dynamic form-based workflows:

- jsm_get_form_templates: List form templates in a project with request type bindings
- jsm_get_form_structure: Get full form design (questions, layout, conditions, sections)
- jsm_get_issue_forms: List forms attached to an issue with submission status

All endpoints validated against the official Atlassian Forms REST API OpenAPI spec.
Uses the Forms Cloud API base URL (jira/forms/cloud/{cloudId}) with X-ExperimentalApi header.

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

* fix(jsm): add input validation and extract shared error parser

- Add validateJiraIssueKey for projectIdOrKey in templates and structure routes
- Add validateJiraCloudId for formId (UUID) in structure route
- Extract parseJsmErrorMessage to shared utils.ts (was duplicated across 3 routes)

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

* chore(jsm): remove unused FORM_QUESTION_PROPERTIES constant

Dead code — the get_form_structure tool passes the raw design object
through as JSON, so this output constant had no consumers.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 13:58:41 -07:00
Waleed
7bd271ae5b fix(credentials): add cross-cache invalidation for oauth credential queries (#4076) 2026-04-09 11:32:08 -07:00
Waleed
8e222fa369 improvement(polling): fix correctness and efficiency across all polling handlers (#4067)
* improvement(polling): fix correctness and efficiency across all polling handlers

- Gmail: paginate history API, add historyTypes filter, differentiate 403/429,
  fetch fresh historyId on fallback to break 404 retry loop
- Outlook: follow @odata.nextLink pagination, use fetchWithRetry for all Graph
  calls, fix $top alignment, skip folder filter on partial resolution failure,
  remove Content-Type from GET requests
- RSS: add conditional GET (ETag/If-None-Match), raise GUID cap to 500, fix 304
  ETag capture per RFC 9111, align GUID tracking with idempotency fallback key
- IMAP: single connection reuse, UIDVALIDITY tracking per mailbox, advance UID
  only on successful fetch, fix messageFlagsAdd range type, remove cross-mailbox
  legacy UID fallback
- Dispatch polling via trigger.dev task with per-provider concurrency key;
  fall back to synchronous Redis-locked polling for self-hosted

* fix(rss): align idempotency key GUID fallback with tracking/filter guard

* removed comments

* fix(imap): clear stale UID when UIDVALIDITY changes during state merge

* fix(rss): skip items with no identifiable GUID to avoid idempotency key collisions

* fix(schedules): convert dynamic import of getWorkflowById to static import

* fix(imap): preserve fresh UID after UIDVALIDITY reset in state merge

* improvement(polling): remove trigger.dev dispatch, use synchronous Redis-locked polling

* fix(polling): decouple outlook page size from total email cap so pagination works
2026-04-09 11:22:38 -07:00
Waleed
b67c068817 improvement(deploy): improve auto-generated version descriptions (#4075)
* improvement(deploy): improve auto-generated version descriptions

* fix(deploy): address PR review - log dropdown errors, populate first-deploy details

* lint
2026-04-09 10:51:46 -07:00
Waleed
d778b3d35b fix(trigger): add @react-email/components to additionalPackages (#4068) 2026-04-08 23:26:30 -07:00
Vikhyath Mondreti
dc7d876a34 improvement(release): address comments (#4069) 2026-04-08 23:22:18 -07:00
Waleed
f8f3758649 v0.6.32: BYOK fixes, ui improvements, cloudwatch tools, jsm tools extension 2026-04-08 22:31:21 -07:00
Waleed
db230785d3 fix(jsm): improve create request error handling, add form-based submission support (#4066)
* fix(jsm): improve create request error handling, add form-based submission support

* refactor(jsm): extract parseJsmErrorMessage helper to deduplicate error handling

* fix(jsm): remove required on summary for advanced mode, add JSON.parse error handling

* fix(jsm): include description in requestFieldValues gate for form-only requests
2026-04-08 22:17:01 -07:00
Vikhyath Mondreti
9fbe514dbd fix(hitl): resume workflow output async (#4065) 2026-04-08 19:31:18 -07:00
Theodore Li
139213ef45 feat(block): Add cloudwatch publish operation (#4027)
* feat(block): Add cloudwatch publish operation

* fix(integrations): validate and fix cloudwatch, cloudformation, athena conventions

- Update tool version strings from '1.0' to '1.0.0' across all three integrations
- Add missing `export * from './types'` barrel re-exports (cloudwatch, cloudformation)
- Add docsLink, wandConfig timestamps, mode: 'advanced' on optional fields (cloudwatch)
- Add dropdown defaults, ZodError handling, docs intro section (cloudwatch)
- Add mode: 'advanced' on limit field (cloudformation)
- Alphabetize registry entries (cloudwatch, cloudformation)
- Fix athena docs maxResults range (1-999)

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

* fix(cloudwatch): complete put_metric_data unit dropdown, add missing outputs, fix JSON error handling

- Add all 27 valid CloudWatch StandardUnit values to metricUnit dropdown (was 13)
- Add missing block outputs for put_metric_data: success, namespace, metricName, value, unit
- Add try-catch around dimensions JSON.parse in put-metric-data route for proper 400 errors

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

* fix(cloudwatch): fix DescribeAlarms returning only MetricAlarm when "All Types" selected

Per AWS docs, omitting AlarmTypes returns only MetricAlarm. Now explicitly
sends both MetricAlarm and CompositeAlarm when no filter is selected.

Also fix dimensions JSON parse errors returning 500 instead of 400 in
get-metric-statistics route.

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

* fix(cloudwatch): validate dimensions JSON at Zod schema level

Move dimensions validation from runtime try-catch to Zod refinement,
catching malformed JSON and arrays at schema validation time (400)
instead of runtime (500). Also rejects JSON arrays that would produce
meaningless numeric dimension names.

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

* fix(cloudwatch): reject non-numeric metricValue instead of silently publishing 0

Add NaN guard in block config and .finite() refinement in Zod schema
so "abc" → NaN is caught at both layers instead of coercing to 0.

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

* fix(cloudwatch): use Number.isFinite to also reject Infinity in block config

Aligns block-level validation with route's Zod .finite() refinement so
Infinity/-Infinity are caught at the block config layer, not just the API.

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

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>
Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:02:24 -07:00
Vikhyath Mondreti
a8468a6056 fix(hitl): async resume (#4064)
* fix(hitl): async resume

* fix
2026-04-08 18:46:16 -07:00
Vikhyath Mondreti
3e85218142 improvement(hitl): streaming, async support + update docs (#4058)
* improvement(hitl): support streaming, async, update docs

* update docs

* fix tests

* fix abort signal passthrough

* module level const

* fix form route

* address comments

* fix build
2026-04-08 17:36:33 -07:00
Vikhyath Mondreti
c5cc336847 fix(subscription-state): remove dead code, change token route check (#4062)
* fix(subscription-state): remove dead code, change token route check

* update tests

* remove mock

* improve ux past usage limit
2026-04-08 17:17:32 -07:00
Theodore Li
5f33432dc2 fix(billing): Skip billing on streamed workflows with byok (#4056)
* fix(billing): skip billing on streamed workflows with byok

* Simplify logic

* Address comments, skip tokenization billing fallback

* Fix tool usage billing for streamed outputs

* fix(webhook): throw webhook errors as 4xxs (#4050)

* fix(webhook): throw webhook errors as 4xxs

* Fix shadowing body var

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* feat(enterprise): cloud whitelabeling for enterprise orgs (#4047)

* feat(enterprise): cloud whitelabeling for enterprise orgs

* fix(enterprise): scope enterprise plan check to target org in whitelabel PUT

* fix(enterprise): use isOrganizationOnEnterprisePlan for org-scoped enterprise check

* fix(enterprise): allow clearing whitelabel fields and guard against empty update result

* fix(enterprise): remove webp from logo accept attribute to match upload hook validation

* improvement(billing): use isBillingEnabled instead of isProd for plan gate bypasses

* fix(enterprise): show whitelabeling nav item when billing is enabled on non-hosted environments

* fix(enterprise): accept relative paths for logoUrl since upload API returns /api/files/serve/ paths

* fix(whitelabeling): prevent logo flash on refresh by hiding logo while branding loads

* fix(whitelabeling): wire hover color through CSS token on tertiary buttons

* fix(whitelabeling): show sim logo by default, only replace when org logo loads

* fix(whitelabeling): cache org logo url in localstorage to eliminate flash on repeat visits

* feat(whitelabeling): add wordmark support with drag/drop upload

* updated turbo

* fix(whitelabeling): defer localstorage read to effect to prevent hydration mismatch

* fix(whitelabeling): use layout effect for cache read to eliminate logo flash before paint

* fix(whitelabeling): cache theme css to eliminate color flash before org settings resolve

* fix(whitelabeling): deduplicate HEX_COLOR_REGEX into lib/branding and remove mutation from useCallback deps

* fix(whitelabeling): use cookie-based SSR cache to eliminate brand flash on all page loads

* fix(whitelabeling): use !orgSettings condition to fix SSR brand cache injection

React Query returns isLoading: false with data: undefined during SSR, so the
previous brandingLoading condition was always false on the server — initialCache
was never injected into brandConfig. Changing to !orgSettings correctly applies
the cookie cache both during SSR and while the client-side query loads, eliminating
the logo flash on hard refresh.

* fix(editor): stop highlighting start.input as blue when block is not connected to starter (#4054)

* fix: merge subblock values in auto-layout to prevent losing router context (#4055)

Auto-layout was reading from getWorkflowState() without merging subblock
store values, then persisting stale subblock data to the database. This
caused runtime-edited values (e.g. router_v2 context) to be overwritten
with their initial/empty values whenever auto-layout was triggered.

* fix(whitelabeling): eliminate logo flash by fetching org settings server-side (#4057)

* fix(whitelabeling): eliminate logo flash by fetching org settings server-side

* improvement(whitelabeling): add SVG support for logo and wordmark uploads

* skelly in workspace header

* remove dead code

* fix(whitelabeling): hydration error, SVG support, skeleton shimmer, dead code removal

* fix(whitelabeling): blob preview dep cycle and missing color fallback

* fix(whitelabeling): use brand-accent as color fallback when workspace color is undefined

* chore(whitelabeling): inline hasOrgBrand

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-08 19:24:04 -04:00
Theodore Li
c83349200c fix(error): catch socket auth error as 4xx (#4059)
* fix(error): catch socket auth error as 4xx

* Switch to type guard

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-08 19:07:30 -04:00
waleed
1856635927 fix(whitelabeling): cast activeOrganizationId on session for TS build 2026-04-08 15:54:51 -07:00
Waleed
91ce55e547 fix(whitelabeling): eliminate logo flash by fetching org settings server-side (#4057)
* fix(whitelabeling): eliminate logo flash by fetching org settings server-side

* improvement(whitelabeling): add SVG support for logo and wordmark uploads

* skelly in workspace header

* remove dead code

* fix(whitelabeling): hydration error, SVG support, skeleton shimmer, dead code removal

* fix(whitelabeling): blob preview dep cycle and missing color fallback

* fix(whitelabeling): use brand-accent as color fallback when workspace color is undefined

* chore(whitelabeling): inline hasOrgBrand
2026-04-08 14:07:31 -07:00
Waleed
694f4a5895 fix: merge subblock values in auto-layout to prevent losing router context (#4055)
Auto-layout was reading from getWorkflowState() without merging subblock
store values, then persisting stale subblock data to the database. This
caused runtime-edited values (e.g. router_v2 context) to be overwritten
with their initial/empty values whenever auto-layout was triggered.
2026-04-08 13:25:15 -07:00
Waleed
cf233bb497 v0.6.31: elevenlabs voice, trigger.dev fixes, cloud whitelabeling for enterprises 2026-04-08 12:57:13 -07:00
Waleed
4700590e64 fix(editor): stop highlighting start.input as blue when block is not connected to starter (#4054) 2026-04-08 12:51:13 -07:00
Waleed
1189400167 feat(enterprise): cloud whitelabeling for enterprise orgs (#4047)
* feat(enterprise): cloud whitelabeling for enterprise orgs

* fix(enterprise): scope enterprise plan check to target org in whitelabel PUT

* fix(enterprise): use isOrganizationOnEnterprisePlan for org-scoped enterprise check

* fix(enterprise): allow clearing whitelabel fields and guard against empty update result

* fix(enterprise): remove webp from logo accept attribute to match upload hook validation

* improvement(billing): use isBillingEnabled instead of isProd for plan gate bypasses

* fix(enterprise): show whitelabeling nav item when billing is enabled on non-hosted environments

* fix(enterprise): accept relative paths for logoUrl since upload API returns /api/files/serve/ paths

* fix(whitelabeling): prevent logo flash on refresh by hiding logo while branding loads

* fix(whitelabeling): wire hover color through CSS token on tertiary buttons

* fix(whitelabeling): show sim logo by default, only replace when org logo loads

* fix(whitelabeling): cache org logo url in localstorage to eliminate flash on repeat visits

* feat(whitelabeling): add wordmark support with drag/drop upload

* updated turbo

* fix(whitelabeling): defer localstorage read to effect to prevent hydration mismatch

* fix(whitelabeling): use layout effect for cache read to eliminate logo flash before paint

* fix(whitelabeling): cache theme css to eliminate color flash before org settings resolve

* fix(whitelabeling): deduplicate HEX_COLOR_REGEX into lib/branding and remove mutation from useCallback deps

* fix(whitelabeling): use cookie-based SSR cache to eliminate brand flash on all page loads

* fix(whitelabeling): use !orgSettings condition to fix SSR brand cache injection

React Query returns isLoading: false with data: undefined during SSR, so the
previous brandingLoading condition was always false on the server — initialCache
was never injected into brandConfig. Changing to !orgSettings correctly applies
the cookie cache both during SSR and while the client-side query loads, eliminating
the logo flash on hard refresh.
2026-04-08 12:33:26 -07:00
Theodore Li
621aa65b91 fix(webhook): throw webhook errors as 4xxs (#4050)
* fix(webhook): throw webhook errors as 4xxs

* Fix shadowing body var

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-08 15:30:12 -04:00
Waleed
c21876ab40 fix(trigger): add react-dom and react-email to additionalPackages (#4052) 2026-04-08 11:39:06 -07:00
Theodore Li
a1173ee712 debug(log): Add logging on socket token error (#4051)
Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-08 14:36:02 -04:00
Waleed
579d240cee fix(parallel): remove broken node-counting completion + resolver claim cross-block (#4045)
* fix(parallel): remove broken node-counting completion in parallel blocks

* fix resolver claim

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-04-08 11:05:23 -07:00
Waleed
d7da35ba0b v0.6.30: slack trigger enhancements, connectors performance improvements, secrets performance, polling refactors, drag resources in mothership 2026-04-08 01:00:43 -07:00
Theodore Li
d6ec115348 v0.6.29: login improvements, posthog telemetry (#4026)
* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(login): fix captcha headers for manual login  (#4025)

* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha
2026-04-07 19:11:31 -04:00
Waleed
3f508e445f v0.6.28: new docs, delete confirmation standardization, dagster integration, signup method feature flags, SSO improvements 2026-04-07 14:26:42 -07:00
Waleed
316bc8cdcc v0.6.27: new triggers, mothership improvements, files archive, queueing improvements, posthog, secrets mutations 2026-04-06 22:15:29 -07:00
Waleed
d889f32697 v0.6.26: ui improvements, multiple response blocks, docx previews, ollama fix 2026-04-05 12:33:24 -07:00
Waleed
28af223a9f v0.6.25: cloudwatch, cloudformation, live kb sync, linear fixes, posthog upgrade 2026-04-04 18:39:28 -07:00
Waleed
a54dcbe949 v0.6.24: copilot feedback wiring, captcha fixes 2026-04-04 12:52:05 -07:00
Waleed
0b9019d9a2 v0.6.23: MCP fixes, remove local state in favor of server state, mothership workflow edits via sockets, ui improvements 2026-04-03 23:30:26 -07:00
391 changed files with 31143 additions and 3636 deletions

View File

@@ -1,17 +1,17 @@
---
description: Create webhook triggers for a Sim integration using the generic trigger builder
description: Create webhook or polling triggers for a Sim integration
argument-hint: <service-name>
---
# Add Trigger
You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, polling infrastructure, and how triggers connect to blocks.
## Your Task
1. Research what webhook events the service supports
2. Create the trigger files using the generic builder
3. Create a provider handler if custom auth, formatting, or subscriptions are needed
1. Research what webhook events the service supports — if the service lacks reliable webhooks, use polling
2. Create the trigger files using the generic builder (webhook) or manual config (polling)
3. Create a provider handler (webhook) or polling handler (polling)
4. Register triggers and connect them to the block
## Directory Structure
@@ -146,23 +146,37 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
### Block file (`apps/sim/blocks/blocks/{service}.ts`)
Wire triggers into the block so the trigger UI appears and `generate-docs.ts` discovers them. Two changes are needed:
1. **Spread trigger subBlocks** at the end of the block's `subBlocks` array
2. **Add `triggers` property** after `outputs` with `enabled: true` and `available: [...]`
```typescript
import { getTrigger } from '@/triggers'
export const {Service}Block: BlockConfig = {
// ...
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
subBlocks: [
// Regular tool subBlocks first...
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
],
// ... tools, inputs, outputs ...
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
}
```
**Versioned blocks (V1 + V2):** Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1:
- **V2 uses `...V1Block` spread** (e.g., Google Calendar): Add trigger to V1 — V2 inherits both `subBlocks` and `triggers` automatically.
- **V2 defines its own `subBlocks`** (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it.
- **Single block, no V2** (e.g., Google Drive): Add trigger directly.
`generate-docs.ts` deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in `integrations.json`. Always verify by checking the output after running the script.
## Provider Handler
All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
@@ -327,6 +341,122 @@ export function buildOutputs(): Record<string, TriggerOutput> {
}
```
## Polling Triggers
Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use `buildTriggerSubBlocks` — they define subBlocks manually.
### Directory Structure
```
apps/sim/triggers/{service}/
├── index.ts # Barrel export
└── poller.ts # TriggerConfig with polling: true
apps/sim/lib/webhooks/polling/
└── {service}.ts # PollingProviderHandler implementation
```
### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`)
```typescript
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types'
import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils'
import { processPolledWebhookEvent } from '@/lib/webhooks/processor'
export const {service}PollingHandler: PollingProviderHandler = {
provider: '{service}',
label: '{Service}',
async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> {
const { webhookData, workflowData, requestId, logger } = ctx
const webhookId = webhookData.id
try {
// For OAuth services:
const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger)
const config = webhookData.providerConfig as unknown as {Service}WebhookConfig
// First poll: seed state, emit nothing
if (!config.lastCheckedTimestamp) {
await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger)
await markWebhookSuccess(webhookId, logger)
return 'success'
}
// Fetch changes since last poll, process with idempotency
// ...
await markWebhookSuccess(webhookId, logger)
return 'success'
} catch (error) {
logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error)
await markWebhookFailed(webhookId, logger)
return 'failure'
}
},
}
```
**Key patterns:**
- First poll seeds state and emits nothing (avoids flooding with existing data)
- Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup
- Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow
- Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state
- Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew
### Trigger Config (`apps/sim/triggers/{service}/poller.ts`)
```typescript
import { {Service}Icon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
export const {service}PollingTrigger: TriggerConfig = {
id: '{service}_poller',
name: '{Service} Trigger',
provider: '{service}',
description: 'Triggers when ...',
version: '1.0.0',
icon: {Service}Icon,
polling: true, // REQUIRED — routes to polling infrastructure
subBlocks: [
{ id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true },
// ... service-specific config fields (dropdowns, inputs, switches) ...
{ id: 'triggerSave', type: 'trigger-save', title: '', hideFromPreview: true, mode: 'trigger', triggerId: '{service}_poller' },
{ id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' },
],
outputs: {
// Must match the payload shape from processPolledWebhookEvent
},
}
```
### Registration (3 places)
1. **`apps/sim/triggers/constants.ts`** — add provider to `POLLING_PROVIDERS` Set
2. **`apps/sim/lib/webhooks/polling/registry.ts`** — import handler, add to `POLLING_HANDLERS`
3. **`apps/sim/triggers/registry.ts`** — import trigger config, add to `TRIGGER_REGISTRY`
### Helm Cron Job
Add to `helm/sim/values.yaml` under the existing polling cron jobs:
```yaml
{service}WebhookPoll:
schedule: "*/1 * * * *"
concurrencyPolicy: Forbid
url: "http://sim:3000/api/webhooks/poll/{service}"
```
### Reference Implementations
- Simple: `apps/sim/lib/webhooks/polling/rss.ts` + `apps/sim/triggers/rss/poller.ts`
- Complex (OAuth, attachments): `apps/sim/lib/webhooks/polling/gmail.ts` + `apps/sim/triggers/gmail/poller.ts`
- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts`
- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts`
## Checklist
### Trigger Definition
@@ -352,7 +482,18 @@ export function buildOutputs(): Record<string, TriggerOutput> {
- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
- [ ] API key field uses `password: true`
### Polling Trigger (if applicable)
- [ ] Handler implements `PollingProviderHandler` at `lib/webhooks/polling/{service}.ts`
- [ ] Trigger config has `polling: true` and defines subBlocks manually (no `buildTriggerSubBlocks`)
- [ ] Provider string matches across: trigger config, handler, `POLLING_PROVIDERS`, polling registry
- [ ] `triggerSave` subBlock `triggerId` matches trigger config `id`
- [ ] First poll seeds state and emits nothing
- [ ] Added provider to `POLLING_PROVIDERS` in `triggers/constants.ts`
- [ ] Added handler to `POLLING_HANDLERS` in `lib/webhooks/polling/registry.ts`
- [ ] Added cron job to `helm/sim/values.yaml`
- [ ] Payload shape matches trigger `outputs` schema
### Testing
- [ ] `bun run type-check` passes
- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys
- [ ] Manually verify output keys match trigger `outputs` keys
- [ ] Trigger UI shows correctly in the block

View File

@@ -1,12 +1,12 @@
# Add Trigger
You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, polling infrastructure, and how triggers connect to blocks.
## Your Task
1. Research what webhook events the service supports
2. Create the trigger files using the generic builder
3. Create a provider handler if custom auth, formatting, or subscriptions are needed
1. Research what webhook events the service supports — if the service lacks reliable webhooks, use polling
2. Create the trigger files using the generic builder (webhook) or manual config (polling)
3. Create a provider handler (webhook) or polling handler (polling)
4. Register triggers and connect them to the block
## Directory Structure
@@ -141,23 +141,37 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
### Block file (`apps/sim/blocks/blocks/{service}.ts`)
Wire triggers into the block so the trigger UI appears and `generate-docs.ts` discovers them. Two changes are needed:
1. **Spread trigger subBlocks** at the end of the block's `subBlocks` array
2. **Add `triggers` property** after `outputs` with `enabled: true` and `available: [...]`
```typescript
import { getTrigger } from '@/triggers'
export const {Service}Block: BlockConfig = {
// ...
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
subBlocks: [
// Regular tool subBlocks first...
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
],
// ... tools, inputs, outputs ...
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
}
```
**Versioned blocks (V1 + V2):** Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1:
- **V2 uses `...V1Block` spread** (e.g., Google Calendar): Add trigger to V1 — V2 inherits both `subBlocks` and `triggers` automatically.
- **V2 defines its own `subBlocks`** (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it.
- **Single block, no V2** (e.g., Google Drive): Add trigger directly.
`generate-docs.ts` deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in `integrations.json`. Always verify by checking the output after running the script.
## Provider Handler
All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
@@ -322,6 +336,122 @@ export function buildOutputs(): Record<string, TriggerOutput> {
}
```
## Polling Triggers
Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use `buildTriggerSubBlocks` — they define subBlocks manually.
### Directory Structure
```
apps/sim/triggers/{service}/
├── index.ts # Barrel export
└── poller.ts # TriggerConfig with polling: true
apps/sim/lib/webhooks/polling/
└── {service}.ts # PollingProviderHandler implementation
```
### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`)
```typescript
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types'
import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils'
import { processPolledWebhookEvent } from '@/lib/webhooks/processor'
export const {service}PollingHandler: PollingProviderHandler = {
provider: '{service}',
label: '{Service}',
async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> {
const { webhookData, workflowData, requestId, logger } = ctx
const webhookId = webhookData.id
try {
// For OAuth services:
const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger)
const config = webhookData.providerConfig as unknown as {Service}WebhookConfig
// First poll: seed state, emit nothing
if (!config.lastCheckedTimestamp) {
await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger)
await markWebhookSuccess(webhookId, logger)
return 'success'
}
// Fetch changes since last poll, process with idempotency
// ...
await markWebhookSuccess(webhookId, logger)
return 'success'
} catch (error) {
logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error)
await markWebhookFailed(webhookId, logger)
return 'failure'
}
},
}
```
**Key patterns:**
- First poll seeds state and emits nothing (avoids flooding with existing data)
- Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup
- Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow
- Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state
- Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew
### Trigger Config (`apps/sim/triggers/{service}/poller.ts`)
```typescript
import { {Service}Icon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
export const {service}PollingTrigger: TriggerConfig = {
id: '{service}_poller',
name: '{Service} Trigger',
provider: '{service}',
description: 'Triggers when ...',
version: '1.0.0',
icon: {Service}Icon,
polling: true, // REQUIRED — routes to polling infrastructure
subBlocks: [
{ id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true },
// ... service-specific config fields (dropdowns, inputs, switches) ...
{ id: 'triggerSave', type: 'trigger-save', title: '', hideFromPreview: true, mode: 'trigger', triggerId: '{service}_poller' },
{ id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' },
],
outputs: {
// Must match the payload shape from processPolledWebhookEvent
},
}
```
### Registration (3 places)
1. **`apps/sim/triggers/constants.ts`** — add provider to `POLLING_PROVIDERS` Set
2. **`apps/sim/lib/webhooks/polling/registry.ts`** — import handler, add to `POLLING_HANDLERS`
3. **`apps/sim/triggers/registry.ts`** — import trigger config, add to `TRIGGER_REGISTRY`
### Helm Cron Job
Add to `helm/sim/values.yaml` under the existing polling cron jobs:
```yaml
{service}WebhookPoll:
schedule: "*/1 * * * *"
concurrencyPolicy: Forbid
url: "http://sim:3000/api/webhooks/poll/{service}"
```
### Reference Implementations
- Simple: `apps/sim/lib/webhooks/polling/rss.ts` + `apps/sim/triggers/rss/poller.ts`
- Complex (OAuth, attachments): `apps/sim/lib/webhooks/polling/gmail.ts` + `apps/sim/triggers/gmail/poller.ts`
- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts`
- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts`
## Checklist
### Trigger Definition
@@ -347,7 +477,18 @@ export function buildOutputs(): Record<string, TriggerOutput> {
- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
- [ ] API key field uses `password: true`
### Polling Trigger (if applicable)
- [ ] Handler implements `PollingProviderHandler` at `lib/webhooks/polling/{service}.ts`
- [ ] Trigger config has `polling: true` and defines subBlocks manually (no `buildTriggerSubBlocks`)
- [ ] Provider string matches across: trigger config, handler, `POLLING_PROVIDERS`, polling registry
- [ ] `triggerSave` subBlock `triggerId` matches trigger config `id`
- [ ] First poll seeds state and emits nothing
- [ ] Added provider to `POLLING_PROVIDERS` in `triggers/constants.ts`
- [ ] Added handler to `POLLING_HANDLERS` in `lib/webhooks/polling/registry.ts`
- [ ] Added cron job to `helm/sim/values.yaml`
- [ ] Payload shape matches trigger `outputs` schema
### Testing
- [ ] `bun run type-check` passes
- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys
- [ ] Manually verify output keys match trigger `outputs` keys
- [ ] Trigger UI shows correctly in the block

View File

@@ -48,7 +48,7 @@ jobs:
# Build AMD64 images and push to ECR immediately (+ GHCR for main)
build-amd64:
name: Build AMD64
needs: [test-build, detect-version]
needs: [detect-version]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
@@ -70,7 +70,7 @@ jobs:
ecr_repo_secret: ECR_REALTIME
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
@@ -150,7 +150,7 @@ jobs:
# Build ARM64 images for GHCR (main branch only, runs in parallel)
build-ghcr-arm64:
name: Build ARM64 (GHCR Only)
needs: [test-build, detect-version]
needs: [detect-version]
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
@@ -169,7 +169,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@v3
@@ -264,10 +264,10 @@ jobs:
outputs:
docs_changed: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 2 # Need at least 2 commits to detect changes
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -294,7 +294,7 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: staging
token: ${{ secrets.GH_PAT }}
@@ -115,7 +115,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: staging

View File

@@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
@@ -117,7 +117,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@v3

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v5

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -21,7 +21,17 @@ Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der ge
| OpenAI | Knowledge Base-Embeddings, Agent-Block |
| Anthropic | Agent-Block |
| Google | Agent-Block |
| Mistral | Knowledge Base OCR |
| Mistral | Knowledge Base OCR, Agent-Block |
| Fireworks | Agent-Block |
| Firecrawl | Web-Scraping, Crawling, Suche und Extraktion |
| Exa | KI-gestützte Suche und Recherche |
| Serper | Google-Such-API |
| Linkup | Websuche und Inhaltsabruf |
| Parallel AI | Websuche, Extraktion und tiefgehende Recherche |
| Perplexity | KI-gestützter Chat und Websuche |
| Jina AI | Web-Lesen und Suche |
| Google Cloud | Translate, Maps, PageSpeed und Books APIs |
| Brandfetch | Marken-Assets, Logos, Farben und Unternehmensinformationen |
### Einrichtung

View File

@@ -105,9 +105,108 @@ Die Modellaufschlüsselung zeigt:
Die angezeigten Preise entsprechen den Tarifen vom 10. September 2025. Überprüfen Sie die Dokumentation der Anbieter für aktuelle Preise.
</Callout>
## Gehostete Tool-Preise
Wenn Workflows Tool-Blöcke mit den gehosteten API-Schlüsseln von Sim verwenden, werden die Kosten pro Operation berechnet. Verwenden Sie Ihre eigenen Schlüssel über BYOK, um direkt an die Anbieter zu zahlen.
<Tabs items={['Firecrawl', 'Exa', 'Serper', 'Perplexity', 'Linkup', 'Parallel AI', 'Jina AI', 'Google Cloud', 'Brandfetch']}>
<Tab>
**Firecrawl** - Web-Scraping, Crawling, Suche und Extraktion
| Operation | Cost |
|-----------|------|
| Scrape | $0.001 per credit used |
| Crawl | $0.001 per credit used |
| Search | $0.001 per credit used |
| Extract | $0.001 per credit used |
| Map | $0.001 per credit used |
</Tab>
<Tab>
**Exa** - KI-gestützte Suche und Recherche
| Operation | Cost |
|-----------|------|
| Search | Dynamic (returned by API) |
| Get Contents | Dynamic (returned by API) |
| Find Similar Links | Dynamic (returned by API) |
| Answer | Dynamic (returned by API) |
</Tab>
<Tab>
**Serper** - Google-Such-API
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.001 |
| Search (>10 results) | $0.002 |
</Tab>
<Tab>
**Perplexity** - KI-gestützter Chat und Websuche
| Operation | Cost |
|-----------|------|
| Search | $0.005 per request |
| Chat | Token-based (varies by model) |
</Tab>
<Tab>
**Linkup** - Websuche und Inhaltsabruf
| Operation | Cost |
|-----------|------|
| Standard search | ~$0.006 |
| Deep search | ~$0.055 |
</Tab>
<Tab>
**Parallel AI** - Websuche, Extraktion und tiefgehende Recherche
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.005 |
| Search (>10 results) | $0.005 + $0.001 per additional result |
| Extract | $0.001 per URL |
| Deep Research | $0.005$2.40 (varies by processor tier) |
</Tab>
<Tab>
**Jina AI** - Web-Lesen und Suche
| Operation | Cost |
|-----------|------|
| Read URL | $0.20 per 1M tokens |
| Search | $0.20 per 1M tokens (minimum 10K tokens) |
</Tab>
<Tab>
**Google Cloud** - Translate, Maps, PageSpeed und Books APIs
| Operation | Cost |
|-----------|------|
| Translate / Detect | $0.00002 per character |
| Maps (Geocode, Directions, Distance Matrix, Elevation, Timezone, Reverse Geocode, Geolocate, Validate Address) | $0.005 per request |
| Maps (Snap to Roads) | $0.01 per request |
| Maps (Place Details) | $0.017 per request |
| Maps (Places Search) | $0.032 per request |
| PageSpeed | Free |
| Books (Search, Details) | Free |
</Tab>
<Tab>
**Brandfetch** - Marken-Assets, Logos, Farben und Unternehmensinformationen
| Operation | Cost |
|-----------|------|
| Search | Free |
| Get Brand | $0.04 per request |
</Tab>
</Tabs>
## Bring Your Own Key (BYOK)
Sie können Ihre eigenen API-Schlüssel für gehostete Modelle (OpenAI, Anthropic, Google, Mistral) unter **Einstellungen → BYOK** verwenden, um Basispreise zu zahlen. Schlüssel werden verschlüsselt und gelten arbeitsbereichsweit.
Sie können Ihre eigenen API-Schlüssel für unterstützte Anbieter (OpenAI, Anthropic, Google, Mistral, Fireworks, Firecrawl, Exa, Serper, Linkup, Parallel AI, Perplexity, Jina AI, Google Cloud, Brandfetch) unter **Einstellungen → BYOK** verwenden, um Basispreise zu zahlen. Schlüssel werden verschlüsselt und gelten arbeitsbereichsweit.
## Strategien zur Kostenoptimierung

View File

@@ -0,0 +1,9 @@
{
"pages": [
"listPausedExecutions",
"getPausedExecution",
"getPausedExecutionByResumePath",
"getPauseContext",
"resumeExecution"
]
}

View File

@@ -10,6 +10,7 @@
"typescript",
"---Endpoints---",
"(generated)/workflows",
"(generated)/human-in-the-loop",
"(generated)/logs",
"(generated)/usage",
"(generated)/audit-logs",

View File

@@ -78,7 +78,7 @@ Defines the fields approvers fill in when responding. This data becomes availabl
}
```
Access resume data in downstream blocks using `<blockId.resumeInput.fieldName>`.
Access resume data in downstream blocks using `<blockId.fieldName>`.
## Approval Methods
@@ -93,11 +93,12 @@ Access resume data in downstream blocks using `<blockId.resumeInput.fieldName>`.
<Tab>
### REST API
Programmatically resume workflows using the resume endpoint. The `contextId` is available from the block's `resumeEndpoint` output or from the paused execution detail.
Programmatically resume workflows using the resume endpoint. The `contextId` is available from the block's `resumeEndpoint` output or from the `_resume` object in the paused execution response.
```bash
POST /api/resume/{workflowId}/{executionId}/{contextId}
Content-Type: application/json
X-API-Key: your-api-key
{
"input": {
@@ -107,23 +108,56 @@ Access resume data in downstream blocks using `<blockId.resumeInput.fieldName>`.
}
```
The response includes a new `executionId` for the resumed execution:
The resume endpoint automatically respects the execution mode used in the original execute call:
- **Sync mode** (default) — The response waits for the remaining workflow to complete and returns the full result:
```json
{
"status": "started",
"success": true,
"status": "completed",
"executionId": "<resumeExecutionId>",
"message": "Resume execution started."
"output": { ... },
"metadata": { "duration": 1234, "startTime": "...", "endTime": "..." }
}
```
To poll execution progress after resuming, connect to the SSE stream:
If the resumed workflow hits another HITL block, the response returns `"status": "paused"` with new `_resume` URLs in the output.
```bash
GET /api/workflows/{workflowId}/executions/{resumeExecutionId}/stream
- **Stream mode** (`stream: true` on the original execute call) — The resume response streams SSE events with `selectedOutputs` chunks, just like the initial execution.
- **Async mode** (`X-Execution-Mode: async` on the original execute call) — The resume dispatches execution to a background worker and returns immediately with `202`, including a `jobId` and `statusUrl` for polling:
```json
{
"success": true,
"async": true,
"jobId": "<jobId>",
"executionId": "<resumeExecutionId>",
"message": "Resume execution queued",
"statusUrl": "/api/jobs/<jobId>"
}
```
Build custom approval UIs or integrate with existing systems.
#### Polling execution status
Poll the `statusUrl` from the async response to check when the resume completes:
```bash
GET /api/jobs/{jobId}
X-API-Key: your-api-key
```
Returns job status and, when completed, the full workflow output.
To check on a paused execution's pause points and resume links:
```bash
GET /api/resume/{workflowId}/{executionId}
X-API-Key: your-api-key
```
Returns the paused execution detail with all pause points, their statuses, and resume links. Returns `404` when the execution has completed and is no longer paused.
</Tab>
<Tab>
### Webhook
@@ -132,6 +166,53 @@ Access resume data in downstream blocks using `<blockId.resumeInput.fieldName>`.
</Tab>
</Tabs>
## API Execute Behavior
When triggering a workflow via the execute API (`POST /api/workflows/{id}/execute`), HITL blocks cause the execution to pause and return the `_resume` data in the response:
<Tabs items={['Sync (JSON)', 'Stream (SSE)', 'Async']}>
<Tab>
The response includes the full pause data with resume URLs:
```json
{
"success": true,
"executionId": "<executionId>",
"output": {
"data": {
"operation": "human",
"_resume": {
"apiUrl": "/api/resume/{workflowId}/{executionId}/{contextId}",
"uiUrl": "/resume/{workflowId}/{executionId}",
"contextId": "<contextId>",
"executionId": "<executionId>",
"workflowId": "<workflowId>"
}
}
}
}
```
</Tab>
<Tab>
Blocks before the HITL stream their `selectedOutputs` normally. When execution pauses, the final SSE event includes `status: "paused"` and the `_resume` data:
```
data: {"blockId":"agent1","chunk":"streamed content..."}
data: {"event":"final","data":{"success":true,"output":{...,"_resume":{...}},"status":"paused"}}
data: "[DONE]"
```
On resume, blocks after the HITL stream their `selectedOutputs` the same way.
<Callout type="info">
HITL blocks are automatically excluded from the `selectedOutputs` dropdown since their data is always included in the pause response.
</Callout>
</Tab>
<Tab>
Returns `202` immediately. Use the polling endpoint to check when the execution pauses.
</Tab>
</Tabs>
## Common Use Cases
**Content Approval** - Review AI-generated content before publishing
@@ -161,9 +242,9 @@ Agent (Generate) → Human in the Loop (QA) → Gmail (Send)
**`response`** - Display data shown to the approver (json)
**`submission`** - Form submission data from the approver (json)
**`submittedAt`** - ISO timestamp when the workflow was resumed
**`resumeInput.*`** - All fields defined in Resume Form become available after the workflow resumes
**`<fieldName>`** - All fields defined in Resume Form become available at the top level after the workflow resumes
Access using `<blockId.resumeInput.fieldName>`.
Access using `<blockId.fieldName>`.
## Example
@@ -187,7 +268,7 @@ Access using `<blockId.resumeInput.fieldName>`.
**Downstream Usage:**
```javascript
// Condition block
<approval1.resumeInput.approved> === true
<approval1.approved> === true
```
The example below shows an approval portal as seen by an approver after the workflow is paused. Approvers can review the data and provide inputs as a part of the workflow resumption. The approval portal can be accessed directly via the unique URL, `<blockId.url>`.
@@ -204,7 +285,7 @@ The example below shows an approval portal as seen by an approver after the work
<FAQ items={[
{ question: "How long does the workflow stay paused?", answer: "The workflow pauses indefinitely until a human provides input through the approval portal, the REST API, or a webhook. There is no automatic timeout — it will wait until someone responds." },
{ question: "What notification channels can I use to alert approvers?", answer: "You can configure notifications through Slack, Gmail, Microsoft Teams, SMS (via Twilio), or custom webhooks. Include the approval URL in your notification message so approvers can access the portal directly." },
{ question: "How do I access the approver's input in downstream blocks?", answer: "Use the syntax <blockId.resumeInput.fieldName> to reference specific fields from the resume form. For example, if your block ID is 'approval1' and the form has an 'approved' field, use <approval1.resumeInput.approved>." },
{ question: "How do I access the approver's input in downstream blocks?", answer: "Use the syntax <blockId.fieldName> to reference specific fields from the resume form. For example, if your block name is 'approval1' and the form has an 'approved' field, use <approval1.approved>." },
{ question: "Can I chain multiple Human in the Loop blocks for multi-stage approvals?", answer: "Yes. You can place multiple Human in the Loop blocks in sequence to create multi-stage approval workflows. Each block pauses independently and can have its own notification configuration and resume form fields." },
{ question: "Can I resume the workflow programmatically without the portal?", answer: "Yes. Each block exposes a resume API endpoint that you can call with a POST request containing the form data as JSON. This lets you build custom approval UIs or integrate with existing systems like Jira or ServiceNow." },
{ question: "What outputs are available after the workflow resumes?", answer: "The block outputs include the approval portal URL, the resume API endpoint URL, the display data shown to the approver, the form submission data, the raw resume input, and an ISO timestamp of when the workflow was resumed." },

View File

@@ -110,9 +110,108 @@ The model breakdown shows:
Pricing shown reflects rates as of September 10, 2025. Check provider documentation for current pricing.
</Callout>
## Hosted Tool Pricing
When workflows use tool blocks with Sim's hosted API keys, costs are charged per operation. Use your own keys via BYOK to pay providers directly instead.
<Tabs items={['Firecrawl', 'Exa', 'Serper', 'Perplexity', 'Linkup', 'Parallel AI', 'Jina AI', 'Google Cloud', 'Brandfetch']}>
<Tab>
**Firecrawl** - Web scraping, crawling, search, and extraction
| Operation | Cost |
|-----------|------|
| Scrape | $0.001 per credit used |
| Crawl | $0.001 per credit used |
| Search | $0.001 per credit used |
| Extract | $0.001 per credit used |
| Map | $0.001 per credit used |
</Tab>
<Tab>
**Exa** - AI-powered search and research
| Operation | Cost |
|-----------|------|
| Search | Dynamic (returned by API) |
| Get Contents | Dynamic (returned by API) |
| Find Similar Links | Dynamic (returned by API) |
| Answer | Dynamic (returned by API) |
</Tab>
<Tab>
**Serper** - Google search API
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.001 |
| Search (>10 results) | $0.002 |
</Tab>
<Tab>
**Perplexity** - AI-powered chat and web search
| Operation | Cost |
|-----------|------|
| Search | $0.005 per request |
| Chat | Token-based (varies by model) |
</Tab>
<Tab>
**Linkup** - Web search and content retrieval
| Operation | Cost |
|-----------|------|
| Standard search | ~$0.006 |
| Deep search | ~$0.055 |
</Tab>
<Tab>
**Parallel AI** - Web search, extraction, and deep research
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.005 |
| Search (>10 results) | $0.005 + $0.001 per additional result |
| Extract | $0.001 per URL |
| Deep Research | $0.005$2.40 (varies by processor tier) |
</Tab>
<Tab>
**Jina AI** - Web reading and search
| Operation | Cost |
|-----------|------|
| Read URL | $0.20 per 1M tokens |
| Search | $0.20 per 1M tokens (minimum 10K tokens) |
</Tab>
<Tab>
**Google Cloud** - Translate, Maps, PageSpeed, and Books APIs
| Operation | Cost |
|-----------|------|
| Translate / Detect | $0.00002 per character |
| Maps (Geocode, Directions, Distance Matrix, Elevation, Timezone, Reverse Geocode, Geolocate, Validate Address) | $0.005 per request |
| Maps (Snap to Roads) | $0.01 per request |
| Maps (Place Details) | $0.017 per request |
| Maps (Places Search) | $0.032 per request |
| PageSpeed | Free |
| Books (Search, Details) | Free |
</Tab>
<Tab>
**Brandfetch** - Brand assets, logos, colors, and company info
| Operation | Cost |
|-----------|------|
| Search | Free |
| Get Brand | $0.04 per request |
</Tab>
</Tabs>
## Bring Your Own Key (BYOK)
Use your own API keys for AI model providers instead of Sim's hosted keys to pay base prices with no markup.
Use your own API keys for supported providers instead of Sim's hosted keys to pay base prices with no markup.
### Supported Providers
@@ -121,7 +220,17 @@ Use your own API keys for AI model providers instead of Sim's hosted keys to pay
| OpenAI | Knowledge Base embeddings, Agent block |
| Anthropic | Agent block |
| Google | Agent block |
| Mistral | Knowledge Base OCR |
| Mistral | Knowledge Base OCR, Agent block |
| Fireworks | Agent block |
| Firecrawl | Web scraping, crawling, search, and extraction |
| Exa | AI-powered search and research |
| Serper | Google search API |
| Linkup | Web search and content retrieval |
| Parallel AI | Web search, extraction, and deep research |
| Perplexity | AI-powered chat and web search |
| Jina AI | Web reading and search |
| Google Cloud | Translate, Maps, PageSpeed, and Books APIs |
| Brandfetch | Brand assets, logos, colors, and company info |
### Setup
@@ -152,20 +261,20 @@ Each voice session is billed when it starts. In deployed chat voice mode, each c
## Plans
Sim has two paid plan tiers **Pro** and **Max**. Either can be used individually or with a team. Team plans pool credits across all seats in the organization.
Sim has two paid plan tiers - **Pro** and **Max**. Either can be used individually or with a team. Team plans pool credits across all seats in the organization.
| Plan | Price | Credits Included | Daily Refresh |
|------|-------|------------------|---------------|
| **Community** | $0 | 1,000 (one-time) | |
| **Community** | $0 | 1,000 (one-time) | - |
| **Pro** | $25/mo | 6,000/mo | +50/day |
| **Max** | $100/mo | 25,000/mo | +200/day |
| **Enterprise** | Custom | Custom | |
| **Enterprise** | Custom | Custom | - |
To use Pro or Max with a team, select **Get For Team** in subscription settings and choose the tier and number of seats. Credits are pooled across the organization at the per-seat rate (e.g. Max for Teams with 3 seats = 75,000 credits/mo pooled).
### Daily Refresh Credits
Paid plans include a small daily credit allowance that does not count toward your plan limit. Each day, usage up to the daily refresh amount is excluded from billable usage. This allowance resets every 24 hours and does not carry over use it or lose it.
Paid plans include a small daily credit allowance that does not count toward your plan limit. Each day, usage up to the daily refresh amount is excluded from billable usage. This allowance resets every 24 hours and does not carry over - use it or lose it.
| Plan | Daily Refresh |
|------|---------------|
@@ -252,7 +361,7 @@ Sim uses a **base subscription + overage** billing model:
### How It Works
**Pro Plan ($25/month 6,000 credits):**
**Pro Plan ($25/month - 6,000 credits):**
- Monthly subscription includes 6,000 credits of usage
- Usage under 6,000 credits → No additional charges
- Usage over 6,000 credits (with on-demand enabled) → Pay the overage at month end

View File

@@ -113,7 +113,7 @@ Retrieve the results of a completed Athena query execution
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `queryExecutionId` | string | Yes | Query execution ID to get results for |
| `maxResults` | number | No | Maximum number of rows to return \(1-1000\) |
| `maxResults` | number | No | Maximum number of rows to return \(1-999\) |
| `nextToken` | string | No | Pagination token from a previous request |
#### Output

View File

@@ -10,6 +10,24 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AWS CloudWatch](https://aws.amazon.com/cloudwatch/) is a monitoring and observability service that provides data and actionable insights for AWS resources, applications, and services. CloudWatch collects monitoring and operational data in the form of logs, metrics, and events, giving you a unified view of your AWS environment.
With the CloudWatch integration, you can:
- **Query Logs (Insights)**: Run CloudWatch Log Insights queries against one or more log groups to analyze log data with a powerful query language
- **Describe Log Groups**: List available CloudWatch log groups in your account, optionally filtered by name prefix
- **Get Log Events**: Retrieve log events from a specific log stream within a log group
- **Describe Log Streams**: List log streams within a log group, ordered by last event time or filtered by name prefix
- **List Metrics**: Browse available CloudWatch metrics, optionally filtered by namespace, metric name, or recent activity
- **Get Metric Statistics**: Retrieve statistical data for a metric over a specified time range with configurable granularity
- **Publish Metric**: Publish custom metric data points to CloudWatch for your own application monitoring
- **Describe Alarms**: List and filter CloudWatch alarms by name prefix, state, or alarm type
In Sim, the CloudWatch integration enables your agents to monitor AWS infrastructure, analyze application logs, track custom metrics, and respond to alarm states as part of automated DevOps and SRE workflows. This is especially powerful when combined with other AWS integrations like CloudFormation and SNS for end-to-end infrastructure management.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.
@@ -155,6 +173,34 @@ Get statistics for a CloudWatch metric over a time range
| `label` | string | Metric label |
| `datapoints` | array | Datapoints with timestamp and statistics values |
### `cloudwatch_put_metric_data`
Publish a custom metric data point to CloudWatch
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `namespace` | string | Yes | Metric namespace \(e.g., Custom/MyApp\) |
| `metricName` | string | Yes | Name of the metric |
| `value` | number | Yes | Metric value to publish |
| `unit` | string | No | Unit of the metric \(e.g., Count, Seconds, Bytes\) |
| `dimensions` | string | No | JSON string of dimension name/value pairs |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the metric was published successfully |
| `namespace` | string | Metric namespace |
| `metricName` | string | Metric name |
| `value` | number | Published metric value |
| `unit` | string | Metric unit |
| `timestamp` | string | Timestamp when the metric was published |
### `cloudwatch_describe_alarms`
List and filter CloudWatch alarms

View File

@@ -113,10 +113,11 @@ Create a new service request in Jira Service Management
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
| `requestTypeId` | string | Yes | Request Type ID \(e.g., "10", "15"\) |
| `summary` | string | Yes | Summary/title for the service request |
| `summary` | string | No | Summary/title for the service request \(required unless using Form Answers\) |
| `description` | string | No | Description for the service request |
| `raiseOnBehalfOf` | string | No | Account ID of customer to raise request on behalf of |
| `requestFieldValues` | json | No | Request field values as key-value pairs \(overrides summary/description if provided\) |
| `formAnswers` | json | No | Form answers for form-based request types \(e.g., \{"summary": \{"text": "Title"\}, "customfield_10010": \{"choices": \["10320"\]\}\}\) |
| `requestParticipants` | string | No | Comma-separated account IDs to add as request participants |
| `channel` | string | No | Channel the request originates from \(e.g., portal, email\) |
@@ -677,4 +678,84 @@ Get the fields required to create a request of a specific type in Jira Service M
| ↳ `defaultValues` | json | Default values for the field |
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
### `jsm_get_form_templates`
List forms (ProForma/JSM Forms) in a Jira project to discover form IDs for request types
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `projectIdOrKey` | string | Yes | Jira project ID or key \(e.g., "10001" or "SD"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `projectIdOrKey` | string | Project ID or key |
| `templates` | array | List of forms in the project |
| ↳ `id` | string | Form template ID \(UUID\) |
| ↳ `name` | string | Form template name |
| ↳ `updated` | string | Last updated timestamp \(ISO 8601\) |
| ↳ `issueCreateIssueTypeIds` | json | Issue type IDs that auto-attach this form on issue create |
| ↳ `issueCreateRequestTypeIds` | json | Request type IDs that auto-attach this form on issue create |
| ↳ `portalRequestTypeIds` | json | Request type IDs that show this form on the customer portal |
| ↳ `recommendedIssueRequestTypeIds` | json | Request type IDs that recommend this form |
| `total` | number | Total number of forms |
### `jsm_get_form_structure`
Get the full structure of a ProForma/JSM form including all questions, field types, choices, layout, and conditions
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `projectIdOrKey` | string | Yes | Jira project ID or key \(e.g., "10001" or "SD"\) |
| `formId` | string | Yes | Form ID \(UUID from Get Form Templates\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `projectIdOrKey` | string | Project ID or key |
| `formId` | string | Form ID |
| `design` | json | Full form design with questions \(field types, labels, choices, validation\), layout \(field ordering\), and conditions |
| `updated` | string | Last updated timestamp |
| `publish` | json | Publishing and request type configuration |
### `jsm_get_issue_forms`
List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., "SD-123", "10001"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `forms` | array | List of forms attached to the issue |
| ↳ `id` | string | Form instance ID \(UUID\) |
| ↳ `name` | string | Form name |
| ↳ `updated` | string | Last updated timestamp \(ISO 8601\) |
| ↳ `submitted` | boolean | Whether the form has been submitted |
| ↳ `lock` | boolean | Whether the form is locked |
| ↳ `internal` | boolean | Whether the form is internal-only |
| ↳ `formTemplateId` | string | Source form template ID \(UUID\) |
| `total` | number | Total number of forms |

View File

@@ -21,7 +21,17 @@ Usa tus propias claves API para proveedores de modelos de IA en lugar de las cla
| OpenAI | Embeddings de base de conocimiento, bloque Agent |
| Anthropic | Bloque Agent |
| Google | Bloque Agent |
| Mistral | OCR de base de conocimiento |
| Mistral | OCR de base de conocimiento, bloque Agent |
| Fireworks | Bloque Agent |
| Firecrawl | Web scraping, crawling, búsqueda y extracción |
| Exa | Búsqueda e investigación impulsada por IA |
| Serper | API de búsqueda de Google |
| Linkup | Búsqueda web y recuperación de contenido |
| Parallel AI | Búsqueda web, extracción e investigación profunda |
| Perplexity | Chat y búsqueda web impulsada por IA |
| Jina AI | Lectura y búsqueda web |
| Google Cloud | APIs de Translate, Maps, PageSpeed y Books |
| Brandfetch | Activos de marca, logos, colores e información de empresas |
### Configuración

View File

@@ -105,9 +105,108 @@ El desglose del modelo muestra:
Los precios mostrados reflejan las tarifas a partir del 10 de septiembre de 2025. Consulta la documentación del proveedor para conocer los precios actuales.
</Callout>
## Precios de herramientas alojadas
Cuando los flujos de trabajo usan bloques de herramientas con las claves API alojadas de Sim, los costos se cobran por operación. Usa tus propias claves a través de BYOK para pagar directamente a los proveedores.
<Tabs items={['Firecrawl', 'Exa', 'Serper', 'Perplexity', 'Linkup', 'Parallel AI', 'Jina AI', 'Google Cloud', 'Brandfetch']}>
<Tab>
**Firecrawl** - Web scraping, crawling, búsqueda y extracción
| Operation | Cost |
|-----------|------|
| Scrape | $0.001 per credit used |
| Crawl | $0.001 per credit used |
| Search | $0.001 per credit used |
| Extract | $0.001 per credit used |
| Map | $0.001 per credit used |
</Tab>
<Tab>
**Exa** - Búsqueda e investigación impulsada por IA
| Operation | Cost |
|-----------|------|
| Search | Dynamic (returned by API) |
| Get Contents | Dynamic (returned by API) |
| Find Similar Links | Dynamic (returned by API) |
| Answer | Dynamic (returned by API) |
</Tab>
<Tab>
**Serper** - API de búsqueda de Google
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.001 |
| Search (>10 results) | $0.002 |
</Tab>
<Tab>
**Perplexity** - Chat y búsqueda web impulsada por IA
| Operation | Cost |
|-----------|------|
| Search | $0.005 per request |
| Chat | Token-based (varies by model) |
</Tab>
<Tab>
**Linkup** - Búsqueda web y recuperación de contenido
| Operation | Cost |
|-----------|------|
| Standard search | ~$0.006 |
| Deep search | ~$0.055 |
</Tab>
<Tab>
**Parallel AI** - Búsqueda web, extracción e investigación profunda
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.005 |
| Search (>10 results) | $0.005 + $0.001 per additional result |
| Extract | $0.001 per URL |
| Deep Research | $0.005$2.40 (varies by processor tier) |
</Tab>
<Tab>
**Jina AI** - Lectura y búsqueda web
| Operation | Cost |
|-----------|------|
| Read URL | $0.20 per 1M tokens |
| Search | $0.20 per 1M tokens (minimum 10K tokens) |
</Tab>
<Tab>
**Google Cloud** - APIs de Translate, Maps, PageSpeed y Books
| Operation | Cost |
|-----------|------|
| Translate / Detect | $0.00002 per character |
| Maps (Geocode, Directions, Distance Matrix, Elevation, Timezone, Reverse Geocode, Geolocate, Validate Address) | $0.005 per request |
| Maps (Snap to Roads) | $0.01 per request |
| Maps (Place Details) | $0.017 per request |
| Maps (Places Search) | $0.032 per request |
| PageSpeed | Free |
| Books (Search, Details) | Free |
</Tab>
<Tab>
**Brandfetch** - Activos de marca, logos, colores e información de empresas
| Operation | Cost |
|-----------|------|
| Search | Free |
| Get Brand | $0.04 per request |
</Tab>
</Tabs>
## Trae tu propia clave (BYOK)
Puedes usar tus propias claves API para modelos alojados (OpenAI, Anthropic, Google, Mistral) en **Configuración → BYOK** para pagar precios base. Las claves están encriptadas y se aplican a todo el espacio de trabajo.
Puedes usar tus propias claves API para proveedores compatibles (OpenAI, Anthropic, Google, Mistral, Fireworks, Firecrawl, Exa, Serper, Linkup, Parallel AI, Perplexity, Jina AI, Google Cloud, Brandfetch) en **Configuración → BYOK** para pagar precios base. Las claves están encriptadas y se aplican a todo el espacio de trabajo.
## Estrategias de optimización de costos

View File

@@ -21,7 +21,17 @@ Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des
| OpenAI | Embeddings de base de connaissances, bloc Agent |
| Anthropic | Bloc Agent |
| Google | Bloc Agent |
| Mistral | OCR de base de connaissances |
| Mistral | OCR de base de connaissances, bloc Agent |
| Fireworks | Bloc Agent |
| Firecrawl | Web scraping, crawling, recherche et extraction |
| Exa | Recherche et investigation alimentées par l'IA |
| Serper | API de recherche Google |
| Linkup | Recherche web et récupération de contenu |
| Parallel AI | Recherche web, extraction et recherche approfondie |
| Perplexity | Chat et recherche web alimentés par l'IA |
| Jina AI | Lecture et recherche web |
| Google Cloud | APIs Translate, Maps, PageSpeed et Books |
| Brandfetch | Ressources de marque, logos, couleurs et informations d'entreprise |
### Configuration

View File

@@ -105,9 +105,108 @@ La répartition des modèles montre :
Les prix indiqués reflètent les tarifs en date du 10 septembre 2025. Consultez la documentation des fournisseurs pour les tarifs actuels.
</Callout>
## Tarification des outils hébergés
Lorsque les workflows utilisent des blocs d'outils avec les clés API hébergées par Sim, les coûts sont facturés par opération. Utilisez vos propres clés via BYOK pour payer directement les fournisseurs.
<Tabs items={['Firecrawl', 'Exa', 'Serper', 'Perplexity', 'Linkup', 'Parallel AI', 'Jina AI', 'Google Cloud', 'Brandfetch']}>
<Tab>
**Firecrawl** - Web scraping, crawling, recherche et extraction
| Operation | Cost |
|-----------|------|
| Scrape | $0.001 per credit used |
| Crawl | $0.001 per credit used |
| Search | $0.001 per credit used |
| Extract | $0.001 per credit used |
| Map | $0.001 per credit used |
</Tab>
<Tab>
**Exa** - Recherche et investigation alimentées par l'IA
| Operation | Cost |
|-----------|------|
| Search | Dynamic (returned by API) |
| Get Contents | Dynamic (returned by API) |
| Find Similar Links | Dynamic (returned by API) |
| Answer | Dynamic (returned by API) |
</Tab>
<Tab>
**Serper** - API de recherche Google
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.001 |
| Search (>10 results) | $0.002 |
</Tab>
<Tab>
**Perplexity** - Chat et recherche web alimentés par l'IA
| Operation | Cost |
|-----------|------|
| Search | $0.005 per request |
| Chat | Token-based (varies by model) |
</Tab>
<Tab>
**Linkup** - Recherche web et récupération de contenu
| Operation | Cost |
|-----------|------|
| Standard search | ~$0.006 |
| Deep search | ~$0.055 |
</Tab>
<Tab>
**Parallel AI** - Recherche web, extraction et recherche approfondie
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.005 |
| Search (>10 results) | $0.005 + $0.001 per additional result |
| Extract | $0.001 per URL |
| Deep Research | $0.005$2.40 (varies by processor tier) |
</Tab>
<Tab>
**Jina AI** - Lecture et recherche web
| Operation | Cost |
|-----------|------|
| Read URL | $0.20 per 1M tokens |
| Search | $0.20 per 1M tokens (minimum 10K tokens) |
</Tab>
<Tab>
**Google Cloud** - APIs Translate, Maps, PageSpeed et Books
| Operation | Cost |
|-----------|------|
| Translate / Detect | $0.00002 per character |
| Maps (Geocode, Directions, Distance Matrix, Elevation, Timezone, Reverse Geocode, Geolocate, Validate Address) | $0.005 per request |
| Maps (Snap to Roads) | $0.01 per request |
| Maps (Place Details) | $0.017 per request |
| Maps (Places Search) | $0.032 per request |
| PageSpeed | Free |
| Books (Search, Details) | Free |
</Tab>
<Tab>
**Brandfetch** - Ressources de marque, logos, couleurs et informations d'entreprise
| Operation | Cost |
|-----------|------|
| Search | Free |
| Get Brand | $0.04 per request |
</Tab>
</Tabs>
## Apportez votre propre clé (BYOK)
Vous pouvez utiliser vos propres clés API pour les modèles hébergés (OpenAI, Anthropic, Google, Mistral) dans **Paramètres → BYOK** pour payer les prix de base. Les clés sont chiffrées et s'appliquent à l'ensemble de l'espace de travail.
Vous pouvez utiliser vos propres clés API pour les fournisseurs pris en charge (OpenAI, Anthropic, Google, Mistral, Fireworks, Firecrawl, Exa, Serper, Linkup, Parallel AI, Perplexity, Jina AI, Google Cloud, Brandfetch) dans **Paramètres → BYOK** pour payer les prix de base. Les clés sont chiffrées et s'appliquent à l'ensemble de l'espace de travail.
## Stratégies d'optimisation des coûts

View File

@@ -20,7 +20,17 @@ Simのホストキーの代わりに、AIモデルプロバイダー用の独自
| OpenAI | ナレッジベースの埋め込み、エージェントブロック |
| Anthropic | エージェントブロック |
| Google | エージェントブロック |
| Mistral | ナレッジベースOCR |
| Mistral | ナレッジベースOCR、エージェントブロック |
| Fireworks | エージェントブロック |
| Firecrawl | Webスクレイピング、クローリング、検索、抽出 |
| Exa | AI搭載の検索とリサーチ |
| Serper | Google検索API |
| Linkup | Web検索とコンテンツ取得 |
| Parallel AI | Web検索、抽出、ディープリサーチ |
| Perplexity | AI搭載のチャットとWeb検索 |
| Jina AI | Web閲覧と検索 |
| Google Cloud | Translate、Maps、PageSpeed、Books API |
| Brandfetch | ブランドアセット、ロゴ、カラー、企業情報 |
### セットアップ

View File

@@ -105,9 +105,108 @@ AIブロックを使用するワークフローでは、ログで詳細なコス
表示価格は2025年9月10日時点のレートを反映しています。最新の価格については各プロバイダーのドキュメントをご確認ください。
</Callout>
## ホスティングツールの料金
ワークフローがSimのホスティングAPIキーを使用するツールブロックを利用する場合、操作ごとに料金が発生します。BYOKで独自のキーを使用すると、プロバイダーに直接支払うことができます。
<Tabs items={['Firecrawl', 'Exa', 'Serper', 'Perplexity', 'Linkup', 'Parallel AI', 'Jina AI', 'Google Cloud', 'Brandfetch']}>
<Tab>
**Firecrawl** - Webスクレイピング、クローリング、検索、抽出
| Operation | Cost |
|-----------|------|
| Scrape | $0.001 per credit used |
| Crawl | $0.001 per credit used |
| Search | $0.001 per credit used |
| Extract | $0.001 per credit used |
| Map | $0.001 per credit used |
</Tab>
<Tab>
**Exa** - AI搭載の検索とリサーチ
| Operation | Cost |
|-----------|------|
| Search | Dynamic (returned by API) |
| Get Contents | Dynamic (returned by API) |
| Find Similar Links | Dynamic (returned by API) |
| Answer | Dynamic (returned by API) |
</Tab>
<Tab>
**Serper** - Google検索API
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.001 |
| Search (>10 results) | $0.002 |
</Tab>
<Tab>
**Perplexity** - AI搭載のチャットとWeb検索
| Operation | Cost |
|-----------|------|
| Search | $0.005 per request |
| Chat | Token-based (varies by model) |
</Tab>
<Tab>
**Linkup** - Web検索とコンテンツ取得
| Operation | Cost |
|-----------|------|
| Standard search | ~$0.006 |
| Deep search | ~$0.055 |
</Tab>
<Tab>
**Parallel AI** - Web検索、抽出、ディープリサーチ
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.005 |
| Search (>10 results) | $0.005 + $0.001 per additional result |
| Extract | $0.001 per URL |
| Deep Research | $0.005$2.40 (varies by processor tier) |
</Tab>
<Tab>
**Jina AI** - Web閲覧と検索
| Operation | Cost |
|-----------|------|
| Read URL | $0.20 per 1M tokens |
| Search | $0.20 per 1M tokens (minimum 10K tokens) |
</Tab>
<Tab>
**Google Cloud** - Translate、Maps、PageSpeed、Books API
| Operation | Cost |
|-----------|------|
| Translate / Detect | $0.00002 per character |
| Maps (Geocode, Directions, Distance Matrix, Elevation, Timezone, Reverse Geocode, Geolocate, Validate Address) | $0.005 per request |
| Maps (Snap to Roads) | $0.01 per request |
| Maps (Place Details) | $0.017 per request |
| Maps (Places Search) | $0.032 per request |
| PageSpeed | Free |
| Books (Search, Details) | Free |
</Tab>
<Tab>
**Brandfetch** - ブランドアセット、ロゴ、カラー、企業情報
| Operation | Cost |
|-----------|------|
| Search | Free |
| Get Brand | $0.04 per request |
</Tab>
</Tabs>
## Bring Your Own Key (BYOK)
ホストされたモデルOpenAI、Anthropic、Google、Mistralに対して、**設定 → BYOK**で独自のAPIキーを使用し、基本価格で支払うことができます。キーは暗号化され、ワークスペース全体に適用されます。
対応プロバイダーOpenAI、Anthropic、Google、Mistral、Fireworks、Firecrawl、Exa、Serper、Linkup、Parallel AI、Perplexity、Jina AI、Google Cloud、Brandfetch)に対して、**設定 → BYOK**で独自のAPIキーを使用し、基本価格で支払うことができます。キーは暗号化され、ワークスペース全体に適用されます。
## コスト最適化戦略

View File

@@ -20,7 +20,17 @@ Sim 企业版为需要更高安全性、合规性和管理能力的组织提供
| OpenAI | 知识库嵌入、Agent 模块 |
| Anthropic | Agent 模块 |
| Google | Agent 模块 |
| Mistral | 知识库 OCR |
| Mistral | 知识库 OCR、Agent 模块 |
| Fireworks | Agent 模块 |
| Firecrawl | 网页抓取、爬取、搜索和提取 |
| Exa | AI 驱动的搜索和研究 |
| Serper | Google 搜索 API |
| Linkup | 网络搜索和内容检索 |
| Parallel AI | 网络搜索、提取和深度研究 |
| Perplexity | AI 驱动的聊天和网络搜索 |
| Jina AI | 网页阅读和搜索 |
| Google Cloud | Translate、Maps、PageSpeed 和 Books API |
| Brandfetch | 品牌资产、标志、颜色和公司信息 |
### 配置方法

View File

@@ -105,9 +105,108 @@ totalCost = baseExecutionCharge + modelCost
显示的价格为截至 2025 年 9 月 10 日的费率。请查看提供商文档以获取最新价格。
</Callout>
## 托管工具定价
当工作流使用 Sim 托管 API 密钥的工具模块时,费用按操作收取。通过 BYOK 使用你自己的密钥可直接向服务商付费。
<Tabs items={['Firecrawl', 'Exa', 'Serper', 'Perplexity', 'Linkup', 'Parallel AI', 'Jina AI', 'Google Cloud', 'Brandfetch']}>
<Tab>
**Firecrawl** - 网页抓取、爬取、搜索和提取
| Operation | Cost |
|-----------|------|
| Scrape | $0.001 per credit used |
| Crawl | $0.001 per credit used |
| Search | $0.001 per credit used |
| Extract | $0.001 per credit used |
| Map | $0.001 per credit used |
</Tab>
<Tab>
**Exa** - AI 驱动的搜索和研究
| Operation | Cost |
|-----------|------|
| Search | Dynamic (returned by API) |
| Get Contents | Dynamic (returned by API) |
| Find Similar Links | Dynamic (returned by API) |
| Answer | Dynamic (returned by API) |
</Tab>
<Tab>
**Serper** - Google 搜索 API
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.001 |
| Search (>10 results) | $0.002 |
</Tab>
<Tab>
**Perplexity** - AI 驱动的聊天和网络搜索
| Operation | Cost |
|-----------|------|
| Search | $0.005 per request |
| Chat | Token-based (varies by model) |
</Tab>
<Tab>
**Linkup** - 网络搜索和内容检索
| Operation | Cost |
|-----------|------|
| Standard search | ~$0.006 |
| Deep search | ~$0.055 |
</Tab>
<Tab>
**Parallel AI** - 网络搜索、提取和深度研究
| Operation | Cost |
|-----------|------|
| Search (≤10 results) | $0.005 |
| Search (>10 results) | $0.005 + $0.001 per additional result |
| Extract | $0.001 per URL |
| Deep Research | $0.005$2.40 (varies by processor tier) |
</Tab>
<Tab>
**Jina AI** - 网页阅读和搜索
| Operation | Cost |
|-----------|------|
| Read URL | $0.20 per 1M tokens |
| Search | $0.20 per 1M tokens (minimum 10K tokens) |
</Tab>
<Tab>
**Google Cloud** - Translate、Maps、PageSpeed 和 Books API
| Operation | Cost |
|-----------|------|
| Translate / Detect | $0.00002 per character |
| Maps (Geocode, Directions, Distance Matrix, Elevation, Timezone, Reverse Geocode, Geolocate, Validate Address) | $0.005 per request |
| Maps (Snap to Roads) | $0.01 per request |
| Maps (Place Details) | $0.017 per request |
| Maps (Places Search) | $0.032 per request |
| PageSpeed | Free |
| Books (Search, Details) | Free |
</Tab>
<Tab>
**Brandfetch** - 品牌资产、标志、颜色和公司信息
| Operation | Cost |
|-----------|------|
| Search | Free |
| Get Brand | $0.04 per request |
</Tab>
</Tabs>
## 自带密钥BYOK
你可以在 **设置 → BYOK** 中为托管模型OpenAI、Anthropic、Google、Mistral使用你自己的 API 密钥,以按基础价格计费。密钥会被加密,并在整个工作区范围内生效。
你可以在 **设置 → BYOK** 中为支持的服务商OpenAI、Anthropic、Google、Mistral、Fireworks、Firecrawl、Exa、Serper、Linkup、Parallel AI、Perplexity、Jina AI、Google Cloud、Brandfetch)使用你自己的 API 密钥,以按基础价格计费。密钥会被加密,并在整个工作区范围内生效。
## 成本优化策略

View File

@@ -25,6 +25,10 @@
"name": "Workflows",
"description": "Execute workflows and manage workflow resources"
},
{
"name": "Human in the Loop",
"description": "Manage paused workflow executions and resume them with input"
},
{
"name": "Logs",
"description": "Query execution logs and retrieve details"
@@ -235,6 +239,544 @@
}
}
},
"/api/workflows/{id}/paused": {
"get": {
"operationId": "listPausedExecutions",
"summary": "List Paused Executions",
"description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.",
"tags": ["Human in the Loop"],
"x-codeSamples": [
{
"id": "curl",
"label": "cURL",
"lang": "bash",
"source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The unique identifier of the workflow.",
"schema": {
"type": "string",
"example": "wf_1a2b3c4d5e"
}
},
{
"name": "status",
"in": "query",
"required": false,
"description": "Filter paused executions by status.",
"schema": {
"type": "string",
"example": "paused"
}
}
],
"responses": {
"200": {
"description": "List of paused executions.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"pausedExecutions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PausedExecutionSummary"
}
}
}
},
"example": {
"pausedExecutions": [
{
"id": "pe_abc123",
"workflowId": "wf_1a2b3c4d5e",
"executionId": "exec_9f8e7d6c5b",
"status": "paused",
"totalPauseCount": 1,
"resumedCount": 0,
"pausedAt": "2026-01-15T10:30:00Z",
"updatedAt": "2026-01-15T10:30:00Z",
"expiresAt": null,
"metadata": null,
"triggerIds": [],
"pausePoints": [
{
"contextId": "ctx_xyz789",
"blockId": "block_hitl_1",
"registeredAt": "2026-01-15T10:30:00Z",
"resumeStatus": "paused",
"snapshotReady": true,
"resumeLinks": {
"apiUrl": "https://www.sim.ai/api/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b/ctx_xyz789",
"uiUrl": "https://www.sim.ai/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b",
"contextId": "ctx_xyz789",
"executionId": "exec_9f8e7d6c5b",
"workflowId": "wf_1a2b3c4d5e"
},
"response": {
"displayData": {
"title": "Approval Required",
"message": "Please review this request"
},
"formFields": []
}
}
]
}
]
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
}
}
},
"/api/workflows/{id}/paused/{executionId}": {
"get": {
"operationId": "getPausedExecution",
"summary": "Get Paused Execution",
"description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.",
"tags": ["Human in the Loop"],
"x-codeSamples": [
{
"id": "curl",
"label": "cURL",
"lang": "bash",
"source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The unique identifier of the workflow.",
"schema": {
"type": "string",
"example": "wf_1a2b3c4d5e"
}
},
{
"name": "executionId",
"in": "path",
"required": true,
"description": "The execution ID of the paused execution.",
"schema": {
"type": "string",
"example": "exec_9f8e7d6c5b"
}
}
],
"responses": {
"200": {
"description": "Paused execution details.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PausedExecutionDetail"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
}
}
},
"/api/resume/{workflowId}/{executionId}": {
"get": {
"operationId": "getPausedExecutionByResumePath",
"summary": "Get Paused Execution (Resume Path)",
"description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.",
"tags": ["Human in the Loop"],
"x-codeSamples": [
{
"id": "curl",
"label": "cURL",
"lang": "bash",
"source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
}
],
"parameters": [
{
"name": "workflowId",
"in": "path",
"required": true,
"description": "The unique identifier of the workflow.",
"schema": {
"type": "string",
"example": "wf_1a2b3c4d5e"
}
},
{
"name": "executionId",
"in": "path",
"required": true,
"description": "The execution ID of the paused execution.",
"schema": {
"type": "string",
"example": "exec_9f8e7d6c5b"
}
}
],
"responses": {
"200": {
"description": "Paused execution details.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PausedExecutionDetail"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"description": "Internal server error.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string",
"description": "Human-readable error message."
}
}
}
}
}
}
}
}
},
"/api/resume/{workflowId}/{executionId}/{contextId}": {
"get": {
"operationId": "getPauseContext",
"summary": "Get Pause Context",
"description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.",
"tags": ["Human in the Loop"],
"x-codeSamples": [
{
"id": "curl",
"label": "cURL",
"lang": "bash",
"source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
}
],
"parameters": [
{
"name": "workflowId",
"in": "path",
"required": true,
"description": "The unique identifier of the workflow.",
"schema": {
"type": "string",
"example": "wf_1a2b3c4d5e"
}
},
{
"name": "executionId",
"in": "path",
"required": true,
"description": "The execution ID of the paused execution.",
"schema": {
"type": "string",
"example": "exec_9f8e7d6c5b"
}
},
{
"name": "contextId",
"in": "path",
"required": true,
"description": "The pause context ID to retrieve details for.",
"schema": {
"type": "string",
"example": "ctx_xyz789"
}
}
],
"responses": {
"200": {
"description": "Pause context details.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PauseContextDetail"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
}
},
"post": {
"operationId": "resumeExecution",
"summary": "Resume Execution",
"description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).",
"tags": ["Human in the Loop"],
"x-codeSamples": [
{
"id": "curl",
"label": "cURL",
"lang": "bash",
"source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'"
}
],
"parameters": [
{
"name": "workflowId",
"in": "path",
"required": true,
"description": "The unique identifier of the workflow.",
"schema": {
"type": "string",
"example": "wf_1a2b3c4d5e"
}
},
{
"name": "executionId",
"in": "path",
"required": true,
"description": "The execution ID of the paused execution.",
"schema": {
"type": "string",
"example": "exec_9f8e7d6c5b"
}
},
{
"name": "contextId",
"in": "path",
"required": true,
"description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.",
"schema": {
"type": "string",
"example": "ctx_xyz789"
}
}
],
"requestBody": {
"description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"input": {
"type": "object",
"description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.",
"additionalProperties": true
}
}
},
"example": {
"input": {
"approved": true,
"comment": "Looks good to me"
}
}
}
}
},
"responses": {
"200": {
"description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ResumeResult"
},
{
"type": "object",
"description": "Resume has been queued behind another in-progress resume.",
"properties": {
"status": {
"type": "string",
"enum": ["queued"],
"description": "Indicates the resume is queued."
},
"executionId": {
"type": "string",
"description": "The execution ID assigned to this resume."
},
"queuePosition": {
"type": "integer",
"description": "Position in the resume queue."
},
"message": {
"type": "string",
"description": "Human-readable status message."
}
}
},
{
"type": "object",
"description": "Resume execution started (non-API-key callers). The execution runs asynchronously.",
"properties": {
"status": {
"type": "string",
"enum": ["started"],
"description": "Indicates the resume execution has started."
},
"executionId": {
"type": "string",
"description": "The execution ID for the resumed workflow."
},
"message": {
"type": "string",
"description": "Human-readable status message."
}
}
}
]
},
"examples": {
"sync": {
"summary": "Synchronous completion",
"value": {
"success": true,
"status": "completed",
"executionId": "exec_new123",
"output": {
"result": "Approved and processed"
},
"error": null,
"metadata": {
"duration": 850,
"startTime": "2026-01-15T10:35:00Z",
"endTime": "2026-01-15T10:35:01Z"
}
}
},
"queued": {
"summary": "Queued behind another resume",
"value": {
"status": "queued",
"executionId": "exec_new123",
"queuePosition": 2,
"message": "Resume queued. It will run after current resumes finish."
}
},
"started": {
"summary": "Execution started (fire and forget)",
"value": {
"status": "started",
"executionId": "exec_new123",
"message": "Resume execution started."
}
}
}
}
}
},
"202": {
"description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AsyncExecutionResult"
},
"example": {
"success": true,
"async": true,
"jobId": "job_4a3b2c1d0e",
"executionId": "exec_new123",
"message": "Resume execution queued",
"statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"503": {
"description": "Failed to queue the resume execution. Retry the request.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string",
"description": "Error message."
}
}
}
}
}
},
"500": {
"description": "Internal server error.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string",
"description": "Human-readable error message."
}
}
}
}
}
}
}
}
},
"/api/v1/workflows": {
"get": {
"operationId": "listWorkflows",
@@ -5788,6 +6330,346 @@
"description": "Upper bound value for 'between' operator."
}
}
},
"PausedExecutionSummary": {
"type": "object",
"description": "Summary of a paused workflow execution.",
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the paused execution record."
},
"workflowId": {
"type": "string",
"description": "The workflow this execution belongs to."
},
"executionId": {
"type": "string",
"description": "The execution that was paused."
},
"status": {
"type": "string",
"description": "Current status of the paused execution.",
"example": "paused"
},
"totalPauseCount": {
"type": "integer",
"description": "Total number of pause points in this execution."
},
"resumedCount": {
"type": "integer",
"description": "Number of pause points that have been resumed."
},
"pausedAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the execution was paused."
},
"updatedAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the paused execution record was last updated."
},
"expiresAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the paused execution will expire and be cleaned up."
},
"metadata": {
"type": "object",
"nullable": true,
"description": "Additional metadata associated with the paused execution.",
"additionalProperties": true
},
"triggerIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "IDs of triggers that initiated the original execution."
},
"pausePoints": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PausePoint"
},
"description": "List of pause points in the execution."
}
}
},
"PausePoint": {
"type": "object",
"description": "A point in the workflow where execution has been paused awaiting human input.",
"properties": {
"contextId": {
"type": "string",
"description": "Unique identifier for this pause context. Used when resuming execution."
},
"blockId": {
"type": "string",
"description": "The block ID where execution paused."
},
"response": {
"description": "Data returned by the block before pausing, including display data and form fields."
},
"registeredAt": {
"type": "string",
"format": "date-time",
"description": "When this pause point was registered."
},
"resumeStatus": {
"type": "string",
"enum": ["paused", "resumed", "failed", "queued", "resuming"],
"description": "Current status of this pause point."
},
"snapshotReady": {
"type": "boolean",
"description": "Whether the execution snapshot is ready for resumption."
},
"resumeLinks": {
"type": "object",
"description": "Links for resuming this pause point.",
"properties": {
"apiUrl": {
"type": "string",
"format": "uri",
"description": "API endpoint URL to POST resume input to."
},
"uiUrl": {
"type": "string",
"format": "uri",
"description": "UI URL for a human to review and approve."
},
"contextId": {
"type": "string",
"description": "The context ID for this pause point."
},
"executionId": {
"type": "string",
"description": "The execution ID."
},
"workflowId": {
"type": "string",
"description": "The workflow ID."
}
}
},
"queuePosition": {
"type": "integer",
"nullable": true,
"description": "Position in the resume queue, if queued."
},
"latestResumeEntry": {
"$ref": "#/components/schemas/ResumeQueueEntry",
"nullable": true,
"description": "The most recent resume queue entry for this pause point."
},
"parallelScope": {
"type": "object",
"description": "Scope information when the pause occurs inside a parallel branch.",
"properties": {
"parallelId": {
"type": "string",
"description": "Identifier of the parallel execution group."
},
"branchIndex": {
"type": "integer",
"description": "Index of the branch within the parallel group."
},
"branchTotal": {
"type": "integer",
"description": "Total number of branches in the parallel group."
}
}
},
"loopScope": {
"type": "object",
"description": "Scope information when the pause occurs inside a loop.",
"properties": {
"loopId": {
"type": "string",
"description": "Identifier of the loop."
},
"iteration": {
"type": "integer",
"description": "Current loop iteration number."
}
}
}
}
},
"ResumeQueueEntry": {
"type": "object",
"description": "An entry in the resume execution queue.",
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for this queue entry."
},
"pausedExecutionId": {
"type": "string",
"description": "The paused execution this entry belongs to."
},
"parentExecutionId": {
"type": "string",
"description": "The original execution that was paused."
},
"newExecutionId": {
"type": "string",
"description": "The new execution ID created for the resume."
},
"contextId": {
"type": "string",
"description": "The pause context ID being resumed."
},
"resumeInput": {
"description": "The input provided when resuming."
},
"status": {
"type": "string",
"description": "Status of this queue entry (e.g., pending, claimed, completed, failed)."
},
"queuedAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the entry was added to the queue."
},
"claimedAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When execution started processing this entry."
},
"completedAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When execution completed."
},
"failureReason": {
"type": "string",
"nullable": true,
"description": "Reason for failure, if the resume failed."
}
}
},
"PausedExecutionDetail": {
"type": "object",
"description": "Detailed information about a paused execution, including the execution snapshot and resume queue.",
"allOf": [
{
"$ref": "#/components/schemas/PausedExecutionSummary"
},
{
"type": "object",
"properties": {
"executionSnapshot": {
"type": "object",
"description": "Serialized execution state for resumption.",
"properties": {
"snapshot": {
"type": "string",
"description": "Serialized execution snapshot data."
},
"triggerIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Trigger IDs from the snapshot."
}
}
},
"queue": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ResumeQueueEntry"
},
"description": "Resume queue entries for this execution."
}
}
}
]
},
"PauseContextDetail": {
"type": "object",
"description": "Detailed information about a specific pause context within a paused execution.",
"properties": {
"execution": {
"$ref": "#/components/schemas/PausedExecutionSummary",
"description": "Summary of the parent paused execution."
},
"pausePoint": {
"$ref": "#/components/schemas/PausePoint",
"description": "The specific pause point for this context."
},
"queue": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ResumeQueueEntry"
},
"description": "Resume queue entries for this context."
},
"activeResumeEntry": {
"$ref": "#/components/schemas/ResumeQueueEntry",
"nullable": true,
"description": "The currently active resume entry, if any."
}
}
},
"ResumeResult": {
"type": "object",
"description": "Result of a synchronous resume execution.",
"properties": {
"success": {
"type": "boolean",
"description": "Whether the resume execution completed successfully."
},
"status": {
"type": "string",
"description": "Execution status.",
"enum": ["completed", "failed", "paused", "cancelled"],
"example": "completed"
},
"executionId": {
"type": "string",
"description": "The new execution ID for the resumed workflow."
},
"output": {
"type": "object",
"description": "Workflow output from the resumed execution.",
"additionalProperties": true
},
"error": {
"type": "string",
"nullable": true,
"description": "Error message if the execution failed."
},
"metadata": {
"type": "object",
"description": "Execution timing metadata.",
"properties": {
"duration": {
"type": "integer",
"description": "Total execution duration in milliseconds."
},
"startTime": {
"type": "string",
"format": "date-time",
"description": "When the resume execution started."
},
"endTime": {
"type": "string",
"format": "date-time",
"description": "When the resume execution completed."
}
}
}
}
}
},
"responses": {

View File

@@ -161,7 +161,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
<h3 className='font-[430] font-season text-lg text-white leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[#F6F6F0]/50 text-sm leading-[150%]'>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>

View File

@@ -110,7 +110,7 @@ export default async function BlogIndex({
<h1 className='text-balance font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
Latest from Sim
</h1>
<p className='max-w-[360px] font-[430] font-season text-[#F6F6F0]/50 text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
<p className='max-w-[540px] font-[430] font-season text-[var(--landing-text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
Announcements, insights, and guides for building AI agent workflows.
</p>
</div>
@@ -152,7 +152,7 @@ export default async function BlogIndex({
<h3 className='font-[430] font-season text-lg text-white leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[#F6F6F0]/50 text-sm leading-[150%]'>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
@@ -191,7 +191,7 @@ export default async function BlogIndex({
<h3 className='font-[430] font-season text-base text-white leading-tight tracking-[-0.01em] lg:text-lg'>
{p.title}
</h3>
<p className='line-clamp-2 text-[#F6F6F0]/40 text-sm leading-[150%]'>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>

View File

@@ -63,10 +63,8 @@ const INTEGRATION_LINKS: FooterItem[] = [
{ label: 'Linear', href: 'https://docs.sim.ai/tools/linear', external: true },
{ label: 'Airtable', href: 'https://docs.sim.ai/tools/airtable', external: true },
{ label: 'Firecrawl', href: 'https://docs.sim.ai/tools/firecrawl', external: true },
{ label: 'Pinecone', href: 'https://docs.sim.ai/tools/pinecone', external: true },
{ label: 'Discord', href: 'https://docs.sim.ai/tools/discord', external: true },
{ label: 'Microsoft Teams', href: 'https://docs.sim.ai/tools/microsoft_teams', external: true },
{ label: 'Outlook', href: 'https://docs.sim.ai/tools/outlook', external: true },
{ label: 'Telegram', href: 'https://docs.sim.ai/tools/telegram', external: true },
]

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -15,46 +16,67 @@ interface LandingFAQProps {
export function LandingFAQ({ faqs }: LandingFAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0)
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
return (
<div className='divide-y divide-[var(--landing-border)]'>
<div>
{faqs.map(({ question, answer }, index) => {
const isOpen = openIndex === index
const isHovered = hoveredIndex === index
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
return (
<div key={question}>
<div
className={cn(
'h-px w-full bg-[var(--landing-bg-elevated)]',
index === 0 || !showDivider ? 'invisible' : 'visible'
)}
/>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
className='flex w-full items-start justify-between gap-4 py-5 text-left'
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
aria-expanded={isOpen}
>
<span
className={cn(
'font-[500] text-[15px] leading-snug transition-colors',
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
isOpen
? 'text-[var(--landing-text)]'
: 'text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
)}
>
{question}
</span>
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-[#555] transition-transform duration-200',
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
{isOpen && (
<div className='pb-5'>
<p className='text-[14px] text-[var(--landing-text-muted)] leading-[1.75]'>
{answer}
</p>
</div>
)}
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
>
<div className='pt-2 pb-4'>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
{answer}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}

View File

@@ -0,0 +1,149 @@
import type { ComponentType, SVGProps } from 'react'
import Link from 'next/link'
import {
AgentIcon,
ApiIcon,
McpIcon,
PackageSearchIcon,
TableIcon,
WorkflowIcon,
} from '@/components/icons'
interface ProductLink {
label: string
description: string
href: string
external?: boolean
icon: ComponentType<SVGProps<SVGSVGElement>>
}
interface SidebarLink {
label: string
href: string
external?: boolean
}
const PLATFORM: ProductLink[] = [
{
label: 'Workflows',
description: 'Visual AI automation builder',
href: 'https://docs.sim.ai/getting-started',
external: true,
icon: WorkflowIcon,
},
{
label: 'Agent',
description: 'Build autonomous AI agents',
href: 'https://docs.sim.ai/blocks/agent',
external: true,
icon: AgentIcon,
},
{
label: 'MCP',
description: 'Connect external tools',
href: 'https://docs.sim.ai/mcp',
external: true,
icon: McpIcon,
},
{
label: 'Knowledge Base',
description: 'Retrieval-augmented context',
href: 'https://docs.sim.ai/knowledgebase',
external: true,
icon: PackageSearchIcon,
},
{
label: 'Tables',
description: 'Structured data storage',
href: 'https://docs.sim.ai/tables',
external: true,
icon: TableIcon,
},
{
label: 'API',
description: 'Deploy workflows as endpoints',
href: 'https://docs.sim.ai/api-reference/getting-started',
external: true,
icon: ApiIcon,
},
]
const EXPLORE: SidebarLink[] = [
{ label: 'Models', href: '/models' },
{ label: 'Integrations', href: '/integrations' },
{ label: 'Changelog', href: '/changelog' },
{ label: 'Self-hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
]
function DropdownLink({ link }: { link: ProductLink }) {
const Icon = link.icon
const Tag = link.external ? 'a' : Link
const props = link.external
? { href: link.href, target: '_blank' as const, rel: 'noopener noreferrer' }
: { href: link.href }
return (
<Tag
{...props}
className='group/item flex items-start gap-2.5 rounded-[5px] px-2.5 py-2 transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
<Icon className='mt-0.5 h-[15px] w-[15px] shrink-0 text-[var(--landing-text-icon)]' />
<div className='flex flex-col'>
<span className='font-[430] font-season text-[13px] text-white leading-tight'>
{link.label}
</span>
<span className='font-season text-[12px] text-[var(--landing-text-subtle)] leading-[150%]'>
{link.description}
</span>
</div>
</Tag>
)
}
export function ProductDropdown() {
return (
<div className='flex w-[560px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] shadow-overlay'>
<div className='flex-1 p-2'>
<div className='mb-1 px-2.5 pt-1'>
<span className='font-[430] font-season text-[11px] text-[var(--landing-text-subtle)] uppercase tracking-[0.08em]'>
Platform
</span>
<div className='mt-1.5 h-px bg-[var(--landing-bg-elevated)]' />
</div>
<div className='grid grid-cols-2'>
{PLATFORM.map((link) => (
<DropdownLink key={link.label} link={link} />
))}
</div>
</div>
<div className='w-px self-stretch bg-[var(--landing-bg-elevated)]' />
<div className='w-[160px] p-2'>
<div className='mb-1 px-2.5 pt-1'>
<span className='font-[430] font-season text-[11px] text-[var(--landing-text-subtle)] uppercase tracking-[0.08em]'>
Explore
</span>
<div className='mt-1.5 h-px bg-[var(--landing-bg-elevated)]' />
</div>
{EXPLORE.map((link) => {
const Tag = link.external ? 'a' : Link
const props = link.external
? { href: link.href, target: '_blank' as const, rel: 'noopener noreferrer' }
: { href: link.href }
return (
<Tag
key={link.label}
{...props}
className='block rounded-[5px] px-2.5 py-1.5 font-[430] font-season text-[13px] text-white transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
{link.label}
</Tag>
)
})}
</div>
</div>
)
}

View File

@@ -2,13 +2,15 @@
import { useRouter } from 'next/navigation'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { cn } from '@/lib/core/utils/cn'
interface TemplateCardButtonProps {
prompt: string
className?: string
children: React.ReactNode
}
export function TemplateCardButton({ prompt, children }: TemplateCardButtonProps) {
export function TemplateCardButton({ prompt, className, children }: TemplateCardButtonProps) {
const router = useRouter()
function handleClick() {
@@ -17,11 +19,7 @@ export function TemplateCardButton({ prompt, children }: TemplateCardButtonProps
}
return (
<button
type='button'
onClick={handleClick}
className='group flex w-full flex-col items-start rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5 text-left transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
>
<button type='button' onClick={handleClick} className={cn('w-full text-left', className)}>
{children}
</button>
)

View File

@@ -283,7 +283,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
}
return (
<>
<section className='bg-[var(--landing-bg)]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
@@ -301,440 +301,434 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
{/* Breadcrumb */}
<nav
aria-label='Breadcrumb'
className='mb-10 flex items-center gap-2 text-[#555] text-[13px]'
>
<Link href='/' className='transition-colors hover:text-[var(--landing-text-muted)]'>
Home
</Link>
<span aria-hidden='true'>/</span>
{/* Hero */}
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<div className='mb-6'>
<Link
href='/integrations'
className='transition-colors hover:text-[var(--landing-text-muted)]'
className='group/link inline-flex items-center gap-1.5 font-season text-[var(--landing-text-muted)] text-sm tracking-[0.02em] hover:text-[var(--landing-text)]'
>
Integrations
</Link>
<span aria-hidden='true'>/</span>
<span className='text-[var(--landing-text-muted)]'>{name}</span>
</nav>
{/* Hero */}
<section aria-labelledby='integration-heading' className='mb-16'>
<div className='mb-6 flex items-center gap-5'>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='h-16 w-16 rounded-xl'
iconClassName='h-8 w-8'
fallbackClassName='text-[26px]'
<svg
className='h-3 w-3 shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
/>
<div>
<p className='mb-0.5 text-[#555] text-[12px]'>Integration</p>
<h1
id='integration-heading'
className='font-[500] text-[36px] text-[var(--landing-text)] leading-tight sm:text-[44px]'
>
{name}
</h1>
</div>
</div>
<p className='mb-8 max-w-[700px] text-[17px] text-[var(--landing-text-muted)] leading-[1.7]'>
{description}
</p>
{/* CTAs */}
<div className='flex flex-wrap gap-2'>
<a
href='https://sim.ai'
className='inline-flex h-[32px] items-center rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-2.5 font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Start building free
</a>
<a
href={docsUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex h-[32px] items-center gap-1.5 rounded-[5px] border border-[var(--landing-border-strong)] px-2.5 font-[430] font-season text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
View docs
<svg
aria-hidden='true'
className='h-3 w-3'
fill='none'
<line
x1='1'
y1='5'
x2='10'
y2='5'
stroke='currentColor'
strokeWidth={2}
viewBox='0 0 24 24'
>
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
<polyline points='15 3 21 3 21 9' />
<line x1='10' x2='21' y1='14' y2='3' />
</svg>
</a>
strokeWidth='1.33'
strokeLinecap='square'
className='origin-right scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M6.5 2L3.5 5L6.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='group-hover/link:-translate-x-[30%] transition-transform duration-200 ease-out'
/>
</svg>
Back to Integrations
</Link>
</div>
{/* Hero content */}
<div className='mb-6 flex items-center gap-5'>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='h-12 w-12 rounded-[5px]'
iconClassName='h-6 w-6'
fallbackClassName='text-[20px]'
aria-hidden='true'
/>
<div>
<h1
id='integration-heading'
className='text-[28px] text-white leading-[100%] tracking-[-0.02em] sm:text-[36px] lg:text-[44px]'
>
{name}
</h1>
</div>
</div>
<p className='mb-8 max-w-[700px] text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em]'>
{description}
</p>
{/* CTAs */}
<div className='flex flex-wrap gap-2'>
<Link
href='/signup'
className='inline-flex h-[32px] items-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Start building free
</Link>
<a
href={docsUrl}
target='_blank'
rel='noopener noreferrer'
className='group/link inline-flex h-[32px] items-center gap-1.5 rounded-[5px] border border-[var(--landing-border-strong)] px-2.5 font-season text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
View docs
<svg
aria-hidden='true'
className='-rotate-45 h-3 w-3 shrink-0'
viewBox='0 0 10 10'
fill='none'
>
<line
x1='0'
y1='5'
x2='9'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M3.5 2L6.5 5L3.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='transition-transform duration-200 ease-out group-hover/link:translate-x-[30%]'
/>
</svg>
</a>
</div>
</div>
{/* Full-width divider */}
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* Border-railed content */}
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
{/* Overview */}
{longDescription && (
<>
<section aria-labelledby='overview-heading' className='px-6 py-10'>
<h2
id='overview-heading'
className='mb-4 text-[20px] text-white leading-[100%] tracking-[-0.02em]'
>
Overview
</h2>
<p className='text-[15px] text-[var(--landing-text-body)] leading-[150%] tracking-[0.02em]'>
{longDescription}
</p>
</section>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
{/* How to automate */}
<section aria-labelledby='how-it-works-heading' className='px-6 py-10'>
<h2
id='how-it-works-heading'
className='mb-6 text-[20px] text-white leading-[100%] tracking-[-0.02em]'
>
How to automate {name} with Sim
</h2>
<ol className='space-y-4' aria-label='Steps to set up automation'>
{[
{
step: '01',
title: 'Create a free account',
body: 'Sign up at sim.ai in seconds. No credit card required. Your workspace is ready immediately.',
},
{
step: '02',
title: `Add a ${name} block`,
body:
authType === 'oauth'
? `Open a workflow, drag a ${name} block onto the canvas, and connect your account with one-click OAuth.`
: authType === 'api-key'
? `Open a workflow, drag a ${name} block onto the canvas, and paste in your ${name} API key.`
: `Open a workflow, drag a ${name} block onto the canvas, and authenticate your account.`,
},
{
step: '03',
title: 'Configure, connect, and run',
body: `Pick the tool you need, wire in an AI agent for reasoning or data transformation, and run. Your ${name} automation is live.`,
},
].map(({ step, title, body }) => (
<li key={step} className='flex gap-4'>
<span
className='mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[var(--landing-border-strong)] font-martian-mono text-[11px] text-[var(--landing-text-subtle)]'
aria-hidden='true'
>
{step}
</span>
<div>
<h3 className='mb-1 text-[15px] text-white tracking-[-0.02em]'>{title}</h3>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[150%] tracking-[0.02em]'>
{body}
</p>
</div>
</li>
))}
</ol>
</section>
{/* Two-column layout */}
<div className='grid grid-cols-1 gap-16 lg:grid-cols-[1fr_300px]'>
{/* Main column */}
<div className='min-w-0 space-y-16'>
{/* Overview */}
{longDescription && (
<section aria-labelledby='overview-heading'>
<h2
id='overview-heading'
className='mb-4 font-[500] text-[20px] text-[var(--landing-text)]'
>
Overview
</h2>
<p className='text-[15px] text-[var(--landing-text-muted)] leading-[1.8]'>
{longDescription}
</p>
</section>
)}
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* How to automate — targets "how to connect X" queries */}
<section aria-labelledby='how-it-works-heading'>
<h2
id='how-it-works-heading'
className='mb-6 font-[500] text-[20px] text-[var(--landing-text)]'
>
How to automate {name} with Sim
</h2>
<ol className='space-y-4' aria-label='Steps to set up automation'>
{[
{
step: '01',
title: 'Create a free account',
body: 'Sign up at sim.ai in seconds. No credit card required. Your workspace is ready immediately.',
},
{
step: '02',
title: `Add a ${name} block`,
body:
authType === 'oauth'
? `Open a workflow, drag a ${name} block onto the canvas, and connect your account with one-click OAuth.`
: authType === 'api-key'
? `Open a workflow, drag a ${name} block onto the canvas, and paste in your ${name} API key.`
: `Open a workflow, drag a ${name} block onto the canvas, and authenticate your account.`,
},
{
step: '03',
title: 'Configure, connect, and run',
body: `Pick the tool you need, wire in an AI agent for reasoning or data transformation, and run. Your ${name} automation is live.`,
},
].map(({ step, title, body }) => (
<li key={step} className='flex gap-4'>
<span
className='mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[var(--landing-border-strong)] font-[500] text-[#555] text-[11px]'
aria-hidden='true'
>
{step}
</span>
<div>
<h3 className='mb-1 font-[500] text-[15px] text-[var(--landing-text)]'>
{title}
</h3>
<p className='text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
{body}
</p>
</div>
</li>
))}
</ol>
</section>
{/* Triggers */}
{triggers.length > 0 && (
<section aria-labelledby='triggers-heading'>
{/* Triggers — rows */}
{triggers.length > 0 && (
<section aria-labelledby='triggers-heading'>
<div className='px-6 pt-10 pb-4'>
<div className='mb-2 flex items-center gap-2.5'>
<span className='relative flex h-2 w-2' aria-hidden='true'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75' />
<span className='relative inline-flex h-2 w-2 rounded-full bg-emerald-500' />
</span>
<h2
id='triggers-heading'
className='mb-2 font-[500] text-[20px] text-[var(--landing-text)]'
className='text-[20px] text-white leading-[100%] tracking-[-0.02em]'
>
Real-time triggers
</h2>
<p className='mb-4 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
Connect a {name} webhook to Sim and your workflow fires the instant an event
happens no polling, no delay. Sim receives the full event payload and makes
every field available as a variable inside your workflow.
</p>
{/* Event cards */}
<ul
className='grid grid-cols-1 gap-3 sm:grid-cols-2'
aria-label={`${name} trigger events`}
>
{triggers.map((trigger) => (
<li
key={trigger.id}
className='rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4'
>
<div className='mb-2 flex items-center gap-2'>
<span className='inline-flex items-center gap-1 rounded-[4px] bg-[var(--landing-bg-elevated)] px-1.5 py-0.5 font-[500] text-[11px] text-[var(--landing-text)]'>
<svg
aria-hidden='true'
className='h-2.5 w-2.5'
fill='none'
stroke='currentColor'
strokeWidth={2.5}
viewBox='0 0 24 24'
>
<polygon points='13 2 3 14 12 14 11 22 21 10 12 10 13 2' />
</svg>
Event
</span>
</div>
<p className='font-[500] text-[13px] text-[var(--landing-text)]'>
{trigger.name}
</div>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[150%] tracking-[0.02em]'>
Connect a {name} webhook to Sim and your workflow fires the instant an event happens
no polling, no delay.
</p>
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{triggers.map((trigger) => (
<div key={trigger.id}>
<div className='flex items-start gap-4 px-6 py-4'>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<p className='text-[14px] text-white leading-snug tracking-[-0.02em]'>
{trigger.name}
</p>
{trigger.description && (
<p className='text-[12px] text-[var(--landing-text-muted)] leading-[150%]'>
{trigger.description}
</p>
{trigger.description && (
<p className='mt-1 text-[12px] text-[var(--landing-text-muted)] leading-relaxed'>
{trigger.description}
</p>
)}
</div>
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</div>
))}
</section>
)}
{/* Workflow templates — horizontal cards */}
{matchingTemplates.length > 0 && (
<section aria-labelledby='templates-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='templates-heading'
className='mb-2 text-[20px] text-white leading-[100%] tracking-[-0.02em]'
>
Workflow templates
</h2>
<p className='text-[14px] text-[var(--landing-text-body)] tracking-[0.02em]'>
Ready-to-use workflows featuring {name}. Click any to build it instantly.
</p>
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{(() => {
const isOdd = matchingTemplates.length % 2 === 1
const pairedTemplates = isOdd ? matchingTemplates.slice(0, -1) : matchingTemplates
const lastTemplate = isOdd ? matchingTemplates[matchingTemplates.length - 1] : null
const resolveTypes = (template: (typeof matchingTemplates)[number]) => [
integration.type,
...template.integrationBlockTypes.filter((bt) => bt !== integration.type),
]
const renderIcons = (allTypes: string[]) =>
allTypes.map((bt, idx) => {
const resolvedBt = byType.get(bt)
? bt
: byType.get(`${bt}_v2`)
? `${bt}_v2`
: byType.get(`${bt}_v3`)
? `${bt}_v3`
: bt
const int = byType.get(resolvedBt)
const ToolIcon = blockTypeToIconMap[resolvedBt]
return (
<span key={bt} className='inline-flex items-center gap-1.5'>
{idx > 0 && (
<span className='text-[#555] text-[11px]' aria-hidden='true'>
</span>
)}
</li>
))}
</ul>
</section>
)}
{/* Workflow templates */}
{matchingTemplates.length > 0 && (
<section aria-labelledby='templates-heading'>
<h2
id='templates-heading'
className='mb-2 font-[500] text-[20px] text-[var(--landing-text)]'
>
Workflow templates
</h2>
<p className='mb-6 text-[14px] text-[var(--landing-text-muted)]'>
Ready-to-use workflows featuring {name}. Click any to build it instantly.
</p>
<ul
className='grid grid-cols-1 gap-4 sm:grid-cols-2'
aria-label='Workflow templates'
>
{matchingTemplates.map((template) => {
const allTypes = [
integration.type,
...template.integrationBlockTypes.filter((bt) => bt !== integration.type),
]
<IntegrationIcon
bgColor={int?.bgColor ?? '#333'}
name={int?.name ?? bt}
Icon={ToolIcon}
as='span'
className='h-6 w-6 rounded-[4px]'
iconClassName='h-3.5 w-3.5'
fallbackClassName='text-[10px]'
aria-hidden='true'
/>
</span>
)
})
return (
<>
{/* Paired rows of 2 */}
{Array.from({ length: Math.ceil(pairedTemplates.length / 2) }, (_, rowIdx) => {
const row = pairedTemplates.slice(rowIdx * 2, rowIdx * 2 + 2)
return (
<li key={template.title}>
<TemplateCardButton prompt={template.prompt}>
{/* Integration pills row */}
<div className='mb-3 flex flex-wrap items-center gap-1.5 text-[12px]'>
{allTypes.map((bt, idx) => {
// Templates may use unversioned keys (e.g. "notion") while the
// icon map has versioned keys ("notion_v2") — fall back to _v2.
const resolvedBt = byType.get(bt)
? bt
: byType.get(`${bt}_v2`)
? `${bt}_v2`
: bt
const int = byType.get(resolvedBt)
const intName = int?.name ?? bt
return (
<span key={bt} className='inline-flex items-center gap-1.5'>
{idx > 0 && (
<span className='text-[#555]' aria-hidden='true'>
</span>
)}
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[var(--landing-bg-elevated)] px-1.5 py-0.5 font-[500] text-[var(--landing-text)]'>
<IntegrationIcon
bgColor={int?.bgColor ?? '#6B7280'}
name={intName}
Icon={blockTypeToIconMap[resolvedBt]}
as='span'
className='h-3.5 w-3.5 rounded-[2px]'
iconClassName='h-2.5 w-2.5'
aria-hidden='true'
/>
{intName}
</span>
</span>
)
})}
</div>
<p className='mb-1 font-[500] text-[14px] text-[var(--landing-text)]'>
{template.title}
</p>
<p className='mt-3 text-[#555] text-[13px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
Try this workflow
</p>
</TemplateCardButton>
</li>
<div key={rowIdx}>
<nav
aria-label={`Template row ${rowIdx + 1}`}
className='flex flex-col sm:flex-row'
>
{row.map((template) => (
<TemplateCardButton
key={template.title}
prompt={template.prompt}
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--landing-bg-elevated)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='flex items-center gap-1.5'>
{renderIcons(resolveTypes(template))}
</div>
<div className='flex flex-col gap-2'>
<h3 className='text-[14px] text-white leading-snug tracking-[-0.02em]'>
{template.title}
</h3>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{template.prompt}
</p>
</div>
</TemplateCardButton>
))}
</nav>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</div>
)
})}
</ul>
</section>
)}
{/* Tools */}
{operations.length > 0 && (
<section aria-labelledby='tools-heading'>
<h2
id='tools-heading'
className='mb-2 font-[500] text-[20px] text-[var(--landing-text)]'
>
Supported tools
</h2>
<p className='mb-6 text-[14px] text-[var(--landing-text-muted)]'>
{operations.length} {name} tool{operations.length === 1 ? '' : 's'} available in
Sim
</p>
<ul
className='grid grid-cols-1 gap-2 sm:grid-cols-2'
aria-label={`${name} supported tools`}
>
{operations.map((op) => (
<li
key={op.name}
className='rounded-[6px] border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-3.5 py-3'
>
<p className='font-[500] text-[13px] text-[var(--landing-text)]'>{op.name}</p>
{op.description && (
<p className='mt-0.5 text-[#555] text-[12px] leading-relaxed'>
{op.description}
</p>
)}
</li>
))}
</ul>
</section>
)}
{/* FAQ */}
<section aria-labelledby='faq-heading'>
<h2
id='faq-heading'
className='mb-8 font-[500] text-[20px] text-[var(--landing-text)]'
>
Frequently asked questions
</h2>
<IntegrationFAQ faqs={faqs} />
</section>
</div>
{/* Sidebar */}
<aside className='space-y-5' aria-label='Integration details'>
{/* Quick details */}
<div className='rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
<h3 className='mb-4 font-[500] text-[14px] text-[var(--landing-text)]'>Details</h3>
<dl className='space-y-3 text-[13px]'>
{operations.length > 0 && (
<div>
<dt className='text-[#555]'>Tools</dt>
<dd className='text-[var(--landing-text)]'>{operations.length} supported</dd>
</div>
)}
{triggers.length > 0 && (
<div>
<dt className='text-[#555]'>Triggers</dt>
<dd className='text-[var(--landing-text)]'>{triggers.length} available</dd>
</div>
)}
<div>
<dt className='text-[#555]'>Auth</dt>
<dd className='text-[var(--landing-text)]'>
{authType === 'oauth'
? 'One-click OAuth'
: authType === 'api-key'
? 'API key'
: 'None required'}
</dd>
</div>
<div>
<dt className='text-[#555]'>Pricing</dt>
<dd className='text-[var(--landing-text)]'>Free to start</dd>
</div>
</dl>
<div className='mt-5 flex flex-col gap-2'>
<a
href='https://sim.ai'
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--white)] bg-[var(--white)] font-[430] font-season text-[13px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Get started free
</a>
<a
href={docsUrl}
target='_blank'
rel='noopener noreferrer'
className='flex h-[32px] w-full items-center justify-center gap-1.5 rounded-[5px] border border-[var(--landing-border-strong)] font-[430] font-season text-[13px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
View docs
<svg
aria-hidden='true'
className='h-3 w-3'
fill='none'
stroke='currentColor'
strokeWidth={2}
viewBox='0 0 24 24'
>
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
<polyline points='15 3 21 3 21 9' />
<line x1='10' x2='21' y1='14' y2='3' />
</svg>
</a>
</div>
</div>
{/* Related integrations — internal linking for SEO */}
<div className='rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
{relatedIntegrations.length > 0 && (
<>
<h3 className='mb-4 font-[500] text-[14px] text-[var(--landing-text)]'>
Related integrations
</h3>
<ul className='space-y-2'>
{relatedIntegrations.map((rel) => (
<li key={rel.slug}>
<Link
href={`/integrations/${rel.slug}`}
className='flex items-center gap-2.5 rounded-[6px] p-1.5 text-[13px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
>
<IntegrationIcon
bgColor={rel.bgColor}
name={rel.name}
Icon={blockTypeToIconMap[rel.type]}
as='span'
className='h-6 w-6 rounded-[4px]'
iconClassName='h-3.5 w-3.5'
fallbackClassName='text-[10px]'
aria-hidden='true'
/>
{rel.name}
</Link>
</li>
))}
</ul>
{/* Last template as a full-width row when odd */}
{lastTemplate && (
<>
<TemplateCardButton
prompt={lastTemplate.prompt}
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
<div className='flex items-center gap-1.5'>
{renderIcons(resolveTypes(lastTemplate))}
</div>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='text-[14px] text-white leading-snug tracking-[-0.02em]'>
{lastTemplate.title}
</h3>
<p className='line-clamp-1 text-[12px] text-[var(--landing-text-muted)] leading-[150%]'>
{lastTemplate.prompt}
</p>
</div>
</TemplateCardButton>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
</>
)}
<Link
href='/integrations'
className={`block text-[#555] text-[12px] transition-colors hover:text-[var(--landing-text-muted)]${relatedIntegrations.length > 0 ? ' mt-4' : ''}`}
)
})()}
</section>
)}
{/* Supported tools — rows */}
{operations.length > 0 && (
<section aria-labelledby='tools-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='tools-heading'
className='mb-2 text-[20px] text-white leading-[100%] tracking-[-0.02em]'
>
All integrations
</Link>
Supported tools
</h2>
<p className='text-[14px] text-[var(--landing-text-body)] tracking-[0.02em]'>
{operations.length} {name} tool{operations.length === 1 ? '' : 's'} available in Sim
</p>
</div>
</aside>
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{operations.map((op) => (
<div key={op.name}>
<div className='flex items-start gap-4 px-6 py-4'>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<p className='text-[14px] text-white leading-snug tracking-[-0.02em]'>
{op.name}
</p>
{op.description && (
<p className='text-[12px] text-[var(--landing-text-muted)] leading-[150%]'>
{op.description}
</p>
)}
</div>
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</div>
))}
</section>
)}
{/* FAQ — full width */}
<section aria-labelledby='faq-heading' className='px-6 py-10'>
<h2
id='faq-heading'
className='mb-8 text-[20px] text-white leading-[100%] tracking-[-0.02em]'
>
Frequently asked questions
</h2>
<IntegrationFAQ faqs={faqs} />
</section>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* Related integrations — horizontal cards with vertical dividers (blog featured pattern) */}
{relatedIntegrations.length > 0 && (
<>
<nav aria-label='Related integrations' className='flex flex-col sm:flex-row'>
{relatedIntegrations.slice(0, 4).map((rel) => (
<Link
key={rel.slug}
href={`/integrations/${rel.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--landing-bg-elevated)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<IntegrationIcon
bgColor={rel.bgColor}
name={rel.name}
Icon={blockTypeToIconMap[rel.type]}
as='span'
className='h-10 w-10 rounded-[5px]'
aria-hidden='true'
/>
<div className='flex flex-col gap-2'>
<h3 className='text-lg text-white leading-tight tracking-[-0.01em]'>
{rel.name}
</h3>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{rel.description}
</p>
</div>
</Link>
))}
</nav>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
{/* Bottom CTA */}
<section
aria-labelledby='cta-heading'
className='mt-20 rounded-xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-8 text-center sm:p-12'
>
{/* Logo pair: Sim × Integration */}
<section aria-labelledby='cta-heading' className='px-6 py-16 text-center'>
<div className='mx-auto mb-6 flex items-center justify-center gap-3'>
<Image
src='/brandbook/logo/small.png'
@@ -776,22 +770,25 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
</div>
<h2
id='cta-heading'
className='mb-3 font-[500] text-[28px] text-[var(--landing-text)] sm:text-[34px]'
className='mb-3 text-[28px] text-white leading-[100%] tracking-[-0.02em] sm:text-[34px]'
>
Start automating {name} today
</h2>
<p className='mx-auto mb-8 max-w-[480px] text-[16px] text-[var(--landing-text-muted)] leading-relaxed'>
<p className='mx-auto mb-8 max-w-[480px] text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em]'>
Build your first AI workflow with {name} in minutes. Connect to every tool your team
uses. Free to start no credit card required.
</p>
<a
href='https://sim.ai'
className='inline-flex h-[32px] items-center rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-2.5 font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
<Link
href='/signup'
className='inline-flex h-[32px] items-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build for free
</a>
Build for free
</Link>
</section>
</div>
</>
{/* Closing full-width divider */}
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
</section>
)
}

View File

@@ -1,7 +1,7 @@
import type { ComponentType, SVGProps } from 'react'
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import type { Integration } from '@/app/(landing)/integrations/data/types'
import { ChevronArrow } from '@/app/(landing)/models/components/model-primitives'
import { IntegrationIcon } from './integration-icon'
interface IntegrationCardProps {
@@ -9,49 +9,76 @@ interface IntegrationCardProps {
IconComponent?: ComponentType<SVGProps<SVGSVGElement>>
}
/**
* Featured integration card — matches blog featured post pattern.
* Used in flex rows separated by border-l dividers.
*/
export function IntegrationCard({ integration, IconComponent }: IntegrationCardProps) {
const { slug, name, description, bgColor, operationCount, triggerCount } = integration
const { slug, name, description, bgColor } = integration
return (
<Link
href={`/integrations/${slug}`}
className='group flex flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
aria-label={`${name} integration`}
className='group/link flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--landing-bg-elevated)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='mb-3 h-10 w-10 rounded-lg'
className='h-10 w-10 rounded-[5px]'
aria-hidden='true'
/>
{/* Name */}
<h3 className='mb-1 font-[500] text-[14px] text-[var(--landing-text)] leading-snug'>
{name}
</h3>
{/* Description — clamped to 2 lines */}
<p className='mb-3 line-clamp-2 flex-1 text-[12px] text-[var(--landing-text-muted)] leading-relaxed'>
{description}
</p>
{/* Footer row */}
<div className='flex flex-wrap items-center gap-1.5'>
{operationCount > 0 && (
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{operationCount} {operationCount === 1 ? 'tool' : 'tools'}
</Badge>
)}
{triggerCount > 0 && (
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{triggerCount} {triggerCount === 1 ? 'trigger' : 'triggers'}
</Badge>
)}
<span className='ml-auto text-[#555] text-[12px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
Learn more
</span>
<div className='flex flex-col gap-2'>
<h3 className='text-lg text-white leading-tight tracking-[-0.01em]'>{name}</h3>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{description}
</p>
</div>
</Link>
)
}
interface IntegrationRowProps {
integration: Integration
IconComponent?: ComponentType<SVGProps<SVGSVGElement>>
}
/**
* Integration list row — matches blog remaining post pattern.
* Each row followed by an h-px divider.
*/
export function IntegrationRow({ integration, IconComponent }: IntegrationRowProps) {
const { slug, name, description, bgColor } = integration
return (
<>
<Link
href={`/integrations/${slug}`}
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--landing-bg-elevated)]'
aria-label={`${name} integration`}
>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='h-8 w-8 shrink-0 rounded-[5px]'
iconClassName='h-4 w-4'
fallbackClassName='text-[13px]'
aria-hidden='true'
/>
{/* Name + description */}
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='text-[14px] text-white leading-snug tracking-[-0.02em]'>{name}</h3>
<p className='line-clamp-1 hidden text-[12px] text-[var(--landing-text-muted)] leading-[150%] sm:block'>
{description}
</p>
</div>
{/* Animated arrow */}
<ChevronArrow />
</Link>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)
}

View File

@@ -4,12 +4,11 @@ import { useMemo, useState } from 'react'
import { Input } from '@/components/emcn'
import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
import type { Integration } from '@/app/(landing)/integrations/data/types'
import { IntegrationCard } from './integration-card'
import { IntegrationRow } from './integration-card'
const CATEGORY_LABELS: Record<string, string> = {
ai: 'AI',
analytics: 'Analytics',
automation: 'Automation',
communication: 'Communication',
crm: 'CRM',
'customer-support': 'Customer Support',
@@ -21,12 +20,10 @@ const CATEGORY_LABELS: Record<string, string> = {
email: 'Email',
'file-storage': 'File Storage',
hr: 'HR',
media: 'Media',
productivity: 'Productivity',
'sales-intelligence': 'Sales Intelligence',
sales: 'Sales',
search: 'Search',
security: 'Security',
social: 'Social',
other: 'Other',
} as const
@@ -41,8 +38,10 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
const availableCategories = useMemo(() => {
const counts = new Map<string, number>()
for (const i of integrations) {
if (i.integrationType) {
counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
if (i.integrationTypes) {
for (const t of i.integrationTypes) {
counts.set(t, (counts.get(t) || 0) + 1)
}
}
}
return Array.from(counts.entries())
@@ -54,7 +53,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
let results = integrations
if (activeCategory) {
results = results.filter((i) => i.integrationType === activeCategory)
results = results.filter((i) => i.integrationTypes?.includes(activeCategory))
}
const q = query.trim().toLowerCase()
@@ -75,7 +74,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
return (
<div>
<div className='mb-6 flex flex-col gap-4 sm:flex-row sm:items-center'>
<div className='mb-6 flex flex-col gap-4 px-6 sm:flex-row sm:items-center'>
<div className='relative max-w-[480px] flex-1'>
<svg
aria-hidden='true'
@@ -99,14 +98,14 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
</div>
</div>
<div className='mb-8 flex flex-wrap gap-2'>
<div className='mb-6 flex flex-wrap gap-2 px-6'>
<button
type='button'
onClick={() => setActiveCategory(null)}
className={`rounded-md border px-3 py-1 text-[12px] transition-colors ${
className={`rounded-[5px] border px-[9px] py-0.5 text-[13.5px] transition-colors ${
activeCategory === null
? 'border-[#555] bg-[#333] text-[var(--landing-text)]'
: 'border-[var(--landing-border)] bg-transparent text-[var(--landing-text-muted)] hover:border-[var(--landing-border-strong)] hover:text-[var(--landing-text)]'
? 'border-[var(--landing-border-strong)] bg-[var(--landing-bg-elevated)] text-[var(--landing-text)]'
: 'border-[var(--landing-border-strong)] text-[var(--landing-text)] hover:bg-[var(--landing-bg-elevated)]'
}`}
>
All
@@ -116,10 +115,10 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
key={cat}
type='button'
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
className={`rounded-md border px-3 py-1 text-[12px] transition-colors ${
className={`rounded-[5px] border px-[9px] py-0.5 text-[13.5px] transition-colors ${
activeCategory === cat
? 'border-[#555] bg-[#333] text-[var(--landing-text)]'
: 'border-[var(--landing-border)] bg-transparent text-[var(--landing-text-muted)] hover:border-[var(--landing-border-strong)] hover:text-[var(--landing-text)]'
? 'border-[var(--landing-border-strong)] bg-[var(--landing-bg-elevated)] text-[var(--landing-text)]'
: 'border-[var(--landing-border-strong)] text-[var(--landing-text)] hover:bg-[var(--landing-bg-elevated)]'
}`}
>
{CATEGORY_LABELS[cat] || cat}
@@ -127,16 +126,18 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
))}
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{filtered.length === 0 ? (
<p className='py-12 text-center text-[#555] text-[15px]'>
<p className='py-12 text-center text-[15px] text-[var(--landing-text-subtle)]'>
No integrations found
{query ? <> for &ldquo;{query}&rdquo;</> : null}
{activeCategory ? <> in {CATEGORY_LABELS[activeCategory] || activeCategory}</> : null}
</p>
) : (
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
<div>
{filtered.map((integration) => (
<IntegrationCard
<IntegrationRow
key={integration.type}
integration={integration}
IconComponent={blockTypeToIconMap[integration.type]}

View File

@@ -41,9 +41,7 @@ export function IntegrationIcon({
{Icon ? (
<Icon className={cn(iconClassName, 'text-white')} />
) : (
<span className={cn('font-[500] text-white leading-none', fallbackClassName)}>
{name.charAt(0)}
</span>
<span className={cn('text-white leading-none', fallbackClassName)}>{name.charAt(0)}</span>
)}
</Tag>
)

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,6 @@ export interface Integration {
triggerCount: number
authType: AuthType
category: string
integrationType?: string
integrationTypes?: string[]
tags?: string[]
}

View File

@@ -1,5 +1,7 @@
import type { Metadata } from 'next'
import { Badge } from '@/components/emcn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { IntegrationCard } from './components/integration-card'
import { IntegrationGrid } from './components/integration-grid'
import { RequestIntegrationModal } from './components/request-integration-modal'
import { blockTypeToIconMap } from './data/icon-mapping'
@@ -18,6 +20,14 @@ const TOP_NAMES = [...new Set(POPULAR_WORKFLOWS.flatMap((p) => [p.from, p.to]))]
const baseUrl = getBaseUrl()
/** Curated featured integrations — high-recognition services shown as cards. */
const FEATURED_SLUGS = ['slack', 'notion', 'github', 'gmail'] as const
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(
(i): i is Integration => i !== undefined
)
export const metadata: Metadata = {
title: 'Integrations',
description: `Connect ${INTEGRATION_COUNT}+ apps and services with Sim's AI workflow automation. Build intelligent pipelines with ${TOP_NAMES.join(', ')}, and more.`,
@@ -90,7 +100,7 @@ export default function IntegrationsPage() {
}
return (
<>
<section className='bg-[var(--landing-bg)]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
@@ -100,64 +110,81 @@ export default function IntegrationsPage() {
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<div className='mx-auto max-w-[1200px] px-6 py-16 sm:px-8 md:px-12'>
{/* Hero */}
<section aria-labelledby='integrations-heading' className='mb-16'>
{/* Hero */}
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<Badge
variant='blue'
size='md'
dot
className='mb-5 bg-white/10 font-season text-white uppercase tracking-[0.02em]'
>
Integrations
</Badge>
<div className='flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between'>
<h1
id='integrations-heading'
className='mb-4 text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'
className='text-balance text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
>
Integrations
</h1>
<p className='max-w-[640px] text-[18px] text-[var(--landing-text-muted)] leading-relaxed'>
<p className='font-[430] font-season text-[var(--landing-text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
Connect every tool your team uses. Build AI-powered workflows that automate tasks across{' '}
{TOP_NAMES.slice(0, 4).map((name, i, arr) => {
const integration = allIntegrations.find((int) => int.name === name)
const Icon = integration ? blockTypeToIconMap[integration.type] : undefined
return (
<span key={name} className='inline-flex items-center gap-[5px]'>
{Icon && (
<span
aria-hidden='true'
className='inline-flex shrink-0'
style={{ opacity: 0.65 }}
>
<Icon className='h-[0.85em] w-[0.85em]' />
</span>
)}
{name}
{i < arr.length - 1 ? ', ' : ''}
</span>
)
})}
{' and more.'}
{INTEGRATION_COUNT} apps and services.
</p>
</section>
</div>
</div>
{/* Searchable grid — client component */}
{/* Full-width divider */}
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* Border-railed content */}
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
{/* Featured integrations — top */}
{featured.length > 0 && (
<>
<nav aria-label='Featured integrations' className='flex flex-col sm:flex-row'>
{featured.map((integration) => (
<IntegrationCard
key={integration.type}
integration={integration}
IconComponent={blockTypeToIconMap[integration.type]}
/>
))}
</nav>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
{/* All Integrations — search, filters, rows */}
<section aria-labelledby='all-integrations-heading'>
<h2
id='all-integrations-heading'
className='mb-8 font-[500] text-[24px] text-[var(--landing-text)]'
>
All Integrations
</h2>
<div className='px-6 pt-10 pb-4'>
<h2
id='all-integrations-heading'
className='mb-2 text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
All Integrations
</h2>
</div>
<IntegrationGrid integrations={allIntegrations} />
</section>
{/* Integration request */}
<div className='mt-16 flex flex-col items-start gap-3 border-[var(--landing-border)] border-t pt-10 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex flex-col items-start gap-3 px-6 py-6 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='font-[500] text-[15px] text-[var(--landing-text)]'>
<p className='text-[15px] text-white tracking-[-0.02em]'>
Don&apos;t see the integration you need?
</p>
<p className='mt-0.5 text-[#555] text-[13px]'>
<p className='mt-0.5 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
Let us know and we&apos;ll prioritize it.
</p>
</div>
<RequestIntegrationModal />
</div>
</div>
</>
{/* Closing full-width divider */}
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
</section>
)
}

View File

@@ -3,14 +3,7 @@ import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import {
Breadcrumbs,
CapabilityTags,
DetailItem,
ModelCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import { FeaturedModelCard, ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
import {
ALL_CATALOG_MODELS,
buildModelCapabilityFacts,
@@ -165,66 +158,88 @@ export default async function ModelPage({
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: provider.name, href: provider.href },
{ label: model.displayName },
]}
/>
<section className='bg-[var(--landing-bg)]'>
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<div className='mb-6'>
<Link
href={provider.href}
className='group/link inline-flex items-center gap-1.5 font-season text-[var(--landing-text-muted)] text-sm tracking-[0.02em] hover:text-[var(--landing-text)]'
>
<svg
className='h-3 w-3 shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<line
x1='1'
y1='5'
x2='10'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-right scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M6.5 2L3.5 5L6.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='group-hover/link:-translate-x-[30%] transition-transform duration-200 ease-out'
/>
</svg>
Back to {provider.name}
</Link>
</div>
<section aria-labelledby='model-heading' className='mb-14'>
<div className='mb-6 flex items-start gap-4'>
<div className='mb-6 flex items-center gap-5'>
<ProviderIcon
provider={provider}
className='h-16 w-16 rounded-3xl'
className='h-16 w-16 rounded-[5px]'
iconClassName='h-8 w-8'
/>
<div className='min-w-0'>
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
<div>
<p className='mb-0.5 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{provider.name} model
</p>
<h1
id='model-heading'
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
className='text-[28px] text-white leading-[100%] tracking-[-0.02em] sm:text-[36px] lg:text-[44px]'
>
{model.displayName}
</h1>
<p className='mt-2 break-all text-[13px] text-[var(--landing-text-muted)]'>
Model ID: {model.id}
</p>
</div>
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
<p className='mb-8 max-w-[700px] text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em]'>
{model.summary}
{model.bestFor ? ` ${model.bestFor}` : ''}
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<Link
href={provider.href}
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Explore {provider.name} models
</Link>
<div className='flex flex-wrap gap-2'>
<a
href='https://sim.ai'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
className='inline-flex h-[32px] items-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build with this model
</a>
<Link
href={provider.href}
className='inline-flex h-[32px] items-center rounded-[5px] border border-[var(--landing-border-strong)] px-2.5 font-season text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
All {provider.name} models
</Link>
</div>
</section>
</div>
<section
aria-label='Model stats'
className='mb-16 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'
>
<StatCard label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
<StatCard
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
<InfoRow label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
<InfoRow
label='Cached input'
value={
model.pricing.cachedInput !== undefined
@@ -232,158 +247,72 @@ export default async function ModelPage({
: 'N/A'
}
/>
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<StatCard
<InfoRow label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<InfoRow
label='Context window'
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
/>
</section>
<InfoRow
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
: 'Not published'
}
/>
<InfoRow label='Provider' value={provider.name} />
<InfoRow label='Updated' value={formatUpdatedAt(model.pricing.updatedAt)} />
{model.bestFor ? <InfoRow label='Best for' value={model.bestFor} /> : null}
<div className='grid grid-cols-1 gap-16 lg:grid-cols-[1fr_320px]'>
<div className='min-w-0 space-y-16'>
<section aria-labelledby='pricing-heading'>
<h2
id='pricing-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Pricing and limits
</h2>
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Pricing below is generated directly from the provider registry in Sim. All amounts
are listed per one million tokens.
</p>
{capabilityFacts.length > 0 && (
<>
{capabilityFacts.map((item) => (
<InfoRow key={item.label} label={item.label} value={item.value} />
))}
</>
)}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
<DetailItem label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
<DetailItem
label='Cached input'
value={
model.pricing.cachedInput !== undefined
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
/>
<DetailItem
label='Output price'
value={`${formatPrice(model.pricing.output)}/1M`}
/>
<DetailItem label='Updated' value={formatUpdatedAt(model.pricing.updatedAt)} />
<DetailItem
label='Context window'
value={
model.contextWindow
? `${formatTokenCount(model.contextWindow)} tokens`
: 'Unknown'
}
/>
<DetailItem
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
: 'Not published'
}
/>
<DetailItem label='Provider' value={provider.name} />
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
</div>
</section>
<section aria-labelledby='capabilities-heading'>
<h2
id='capabilities-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Capabilities
</h2>
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These capability flags are generated from the provider and model definitions tracked
in Sim.
</p>
<CapabilityTags tags={model.capabilityTags} />
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{capabilityFacts.map((item) => (
<DetailItem key={item.label} label={item.label} value={item.value} />
{relatedModels.length > 0 && (
<>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<nav aria-label='Related models' className='flex flex-col sm:flex-row'>
{relatedModels.slice(0, 3).map((entry) => (
<FeaturedModelCard key={entry.id} provider={provider} model={entry} />
))}
</div>
</section>
</nav>
</>
)}
{relatedModels.length > 0 && (
<section aria-labelledby='related-models-heading'>
<h2
id='related-models-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Related {provider.name} models
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Browse comparable models from the same provider to compare pricing, context
window, and capability coverage.
</p>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{relatedModels.map((entry) => (
<ModelCard key={entry.id} provider={provider} model={entry} />
))}
</div>
</section>
)}
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<section
aria-labelledby='model-faq-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
<section aria-labelledby='model-faq-heading' className='px-6 py-10'>
<h2
id='model-faq-heading'
className='mb-8 text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
<h2
id='model-faq-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqs} />
</div>
</section>
</div>
<aside className='space-y-5' aria-label='Model details'>
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
Quick details
</h2>
<div className='space-y-3'>
<DetailItem label='Display name' value={model.displayName} />
<DetailItem label='Provider' value={provider.name} />
<DetailItem
label='Context tracked'
value={model.contextWindow ? 'Yes' : 'Partial'}
/>
<DetailItem
label='Pricing updated'
value={formatUpdatedAt(model.pricing.updatedAt)}
/>
</div>
Frequently asked questions
</h2>
<div>
<LandingFAQ faqs={faqs} />
</div>
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
Browse more
</h2>
<div className='space-y-2'>
<Link
href={provider.href}
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
>
All {provider.name} models
</Link>
<Link
href='/models'
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
>
Full models directory
</Link>
</div>
</div>
</aside>
</section>
</div>
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
</section>
</>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='flex items-baseline justify-between gap-4 px-6 py-4'>
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{label}
</span>
<span className='text-right text-[14px] text-white leading-snug'>{value}</span>
</div>
</>
)

View File

@@ -1,19 +1,21 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Badge } from '@/components/emcn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import {
Breadcrumbs,
CapabilityTags,
ModelCard,
ProviderCard,
ChevronArrow,
FeaturedModelCard,
FeaturedProviderCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import { ModelTimelineChart } from '@/app/(landing)/models/components/model-timeline-chart'
import {
buildProviderFaqs,
formatPrice,
formatTokenCount,
getProviderBySlug,
getProviderCapabilitySummary,
MODEL_PROVIDERS_WITH_CATALOGS,
TOP_MODEL_PROVIDERS,
} from '@/app/(landing)/models/utils'
@@ -95,7 +97,6 @@ export default async function ProviderModelsPage({
}
const faqs = buildProviderFaqs(provider)
const capabilitySummary = getProviderCapabilitySummary(provider)
const relatedProviders = MODEL_PROVIDERS_WITH_CATALOGS.filter(
(entry) => entry.id !== provider.id && TOP_MODEL_PROVIDERS.includes(entry.name)
).slice(0, 4)
@@ -153,142 +154,149 @@ export default async function ProviderModelsPage({
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: provider.name },
]}
/>
<section className='bg-[var(--landing-bg)]'>
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<div className='mb-6'>
<Link
href='/models'
className='group/link inline-flex items-center gap-1.5 font-season text-[var(--landing-text-muted)] text-sm tracking-[0.02em] hover:text-[var(--landing-text)]'
>
<svg
className='h-3 w-3 shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<line
x1='1'
y1='5'
x2='10'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-right scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M6.5 2L3.5 5L6.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='group-hover/link:-translate-x-[30%] transition-transform duration-200 ease-out'
/>
</svg>
Back to Models
</Link>
</div>
<section aria-labelledby='provider-heading' className='mb-14'>
<div className='mb-6 flex items-center gap-4'>
<ProviderIcon
provider={provider}
className='h-16 w-16 rounded-3xl'
iconClassName='h-8 w-8'
/>
<div>
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
Provider
</p>
<Badge
variant='blue'
size='md'
dot
className='mb-5 bg-white/10 font-season text-white uppercase tracking-[0.02em]'
>
Provider
</Badge>
<div className='flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
<div className='flex items-center gap-4'>
<ProviderIcon
provider={provider}
className='h-12 w-12 rounded-[5px]'
iconClassName='h-6 w-6'
/>
<h1
id='provider-heading'
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
className='font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
>
{provider.name} models
</h1>
</div>
<span className='shrink-0 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{provider.modelCount} models
</span>
</div>
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.summary} Browse every {provider.name} model page generated from Sim&apos;s
provider registry with human-readable names, pricing, context windows, and capability
metadata.
</p>
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
<StatCard label='Models tracked' value={provider.modelCount.toString()} />
<StatCard
label='Default model'
value={provider.defaultModelDisplayName || 'Dynamic'}
compact
/>
<StatCard
label='Metadata coverage'
value={provider.contextInformationAvailable ? 'Tracked' : 'Partial'}
compact
/>
<StatCard
label='Featured models'
value={provider.featuredModels.length.toString()}
compact
/>
</div>
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
{provider.featuredModels.length > 0 && (
<>
<nav aria-label='Featured models' className='flex flex-col sm:flex-row'>
{provider.featuredModels.slice(0, 3).map((model) => (
<FeaturedModelCard key={model.id} provider={provider} model={model} />
))}
</nav>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
<div className='mt-6'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
</section>
<ModelTimelineChart models={provider.models} providerId={provider.id} />
<section aria-labelledby='provider-models-heading' className='mb-16'>
<h2
id='provider-models-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
All {provider.name} models
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Every model below links to a dedicated SEO page with exact pricing, context window,
capability support, and related model recommendations.
</p>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{provider.models.map((model) => (
<ModelCard key={model.id} provider={provider} model={model} />
))}
</div>
</section>
{provider.models.map((model) => (
<div key={model.id}>
<Link
href={model.href}
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='text-[14px] text-white leading-snug tracking-[-0.02em]'>
{model.displayName}
</h3>
<p className='line-clamp-1 hidden text-[12px] text-[var(--landing-text-muted)] leading-[150%] sm:block'>
{model.id}
</p>
</div>
<span className='hidden shrink-0 font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.1em] md:block'>
{formatPrice(model.pricing.input)}/1M in
</span>
<span className='hidden shrink-0 font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.1em] md:block'>
{formatPrice(model.pricing.output)}/1M out
</span>
{model.contextWindow ? (
<span className='hidden shrink-0 font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.1em] lg:block'>
{formatTokenCount(model.contextWindow)} ctx
</span>
) : null}
<ChevronArrow />
</Link>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</div>
))}
<section
aria-labelledby='lineup-snapshot-heading'
className='mb-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='lineup-snapshot-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Lineup snapshot
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
A quick view of the strongest differentiators in the {provider.name} model lineup based
on the metadata currently tracked in Sim.
</p>
{relatedProviders.length > 0 && (
<>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<nav aria-label='Related providers' className='flex flex-col sm:flex-row'>
{relatedProviders.map((entry) => (
<FeaturedProviderCard key={entry.id} provider={entry} />
))}
</nav>
</>
)}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{capabilitySummary.map((item) => (
<StatCard key={item.label} label={item.label} value={item.value} compact />
))}
</div>
</section>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{relatedProviders.length > 0 && (
<section aria-labelledby='related-providers-heading' className='mb-16'>
<section aria-labelledby='provider-faq-heading' className='px-6 py-10'>
<h2
id='related-providers-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
id='provider-faq-heading'
className='mb-8 text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
Compare with other providers
Frequently asked questions
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Explore similar provider hubs to compare model lineups, pricing surfaces, and
long-context coverage across the broader AI ecosystem.
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
{relatedProviders.map((entry) => (
<ProviderCard key={entry.id} provider={entry} />
))}
<div>
<LandingFAQ faqs={faqs} />
</div>
</section>
)}
</div>
<section
aria-labelledby='provider-faq-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='provider-faq-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqs} />
</div>
</section>
</div>
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
</section>
</>
)
}

View File

@@ -0,0 +1,9 @@
import { MODEL_CATALOG_PROVIDERS } from '@/app/(landing)/models/utils'
const colorMap = new Map(
MODEL_CATALOG_PROVIDERS.filter((p) => p.color).map((p) => [p.id, p.color as string])
)
export function getProviderColor(providerId: string): string {
return colorMap.get(providerId) ?? '#888888'
}

View File

@@ -0,0 +1,245 @@
'use client'
import type { ComponentType } from 'react'
import { useMemo } from 'react'
import Link from 'next/link'
import { getProviderColor } from '@/app/(landing)/models/components/consts'
import type { CatalogModel } from '@/app/(landing)/models/utils'
import {
formatPrice,
formatTokenCount,
MODEL_CATALOG_PROVIDERS,
} from '@/app/(landing)/models/utils'
/** Providers that host other providers' models — deprioritized to avoid duplicates. */
const RESELLER_PROVIDERS = new Set(
MODEL_CATALOG_PROVIDERS.filter((p) => p.isReseller).map((p) => p.id)
)
const PROVIDER_ICON_MAP: Record<string, ComponentType<{ className?: string }>> = (() => {
const map: Record<string, ComponentType<{ className?: string }>> = {}
for (const provider of MODEL_CATALOG_PROVIDERS) {
if (provider.icon) {
map[provider.id] = provider.icon
}
}
return map
})()
function selectComparisonModels(models: CatalogModel[]): CatalogModel[] {
const seen = new Set<string>()
const result: CatalogModel[] = []
const sorted = [...models].sort((a, b) => {
const score = (m: CatalogModel) => {
const reseller = RESELLER_PROVIDERS.has(m.providerId) ? -50 : 0
const reasoning = m.capabilities.reasoningEffort || m.capabilities.thinking ? 10 : 0
const context = (m.contextWindow ?? 0) / 100000
return reseller + reasoning + context
}
return score(b) - score(a)
})
for (const model of sorted) {
if (result.length >= 10) break
const nameKey = model.displayName.toLowerCase()
if (seen.has(nameKey)) continue
seen.add(nameKey)
result.push(model)
}
return result
}
interface ModelLabelProps {
model: CatalogModel
}
function ModelLabel({ model }: ModelLabelProps) {
const Icon = PROVIDER_ICON_MAP[model.providerId]
return (
<div className='flex w-[140px] shrink-0 items-center justify-end gap-1.5 sm:w-[180px]'>
{Icon && <Icon className='h-3.5 w-3.5 shrink-0' />}
<span className='truncate font-medium text-[13px] text-[var(--landing-text)] leading-none tracking-[-0.01em]'>
{model.displayName}
</span>
</div>
)
}
interface ChartProps {
models: CatalogModel[]
}
function StackedCostChart({ models }: ChartProps) {
const data = useMemo(() => {
const entries = models
.map((model) => ({
model,
input: model.pricing.input,
output: model.pricing.output,
total: model.pricing.input + model.pricing.output,
}))
.filter((e) => e.total > 0)
.sort((a, b) => a.total - b.total)
const maxTotal = entries.length > 0 ? Math.max(...entries.map((e) => e.total)) : 0
return { entries, maxTotal }
}, [models])
if (data.entries.length === 0) return null
return (
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-1'>
<h3 className='text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'>
Cost
</h3>
<span className='font-[430] font-season text-[var(--landing-text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
Per 1M tokens
</span>
</div>
<div className='flex flex-col gap-1.5'>
{data.entries.map(({ model, input, output, total }) => {
const totalPct = data.maxTotal > 0 ? (total / data.maxTotal) * 100 : 0
const inputPct = total > 0 ? (input / total) * 100 : 0
const color = getProviderColor(model.providerId)
return (
<Link
key={model.id}
href={model.href}
className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
<ModelLabel model={model} />
<div className='relative flex h-7 min-w-0 flex-1 items-center'>
<div
className='flex h-full overflow-hidden rounded-r-[3px]'
style={{ width: `${Math.max(totalPct, 3)}%` }}
>
<div
className='h-full'
style={{
width: `${inputPct}%`,
backgroundColor: color,
opacity: 0.8,
}}
/>
<div
className='h-full'
style={{
width: `${100 - inputPct}%`,
backgroundColor: color,
opacity: 0.35,
}}
/>
</div>
<span className='ml-2.5 shrink-0 font-mono text-[var(--landing-text-muted)] text-xs'>
{formatPrice(input)} input / {formatPrice(output)} output
</span>
</div>
</Link>
)
})}
</div>
</div>
)
}
function ContextWindowChart({ models }: ChartProps) {
const data = useMemo(() => {
const entries = models
.map((model) => ({
model,
value: model.contextWindow,
}))
.filter((e): e is { model: CatalogModel; value: number } => e.value !== null && e.value > 0)
.sort((a, b) => a.value - b.value)
const maxValue = entries.length > 0 ? Math.max(...entries.map((e) => e.value)) : 0
return { entries, maxValue }
}, [models])
if (data.entries.length === 0) return null
return (
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-1'>
<h3 className='text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'>
Context window
</h3>
<span className='font-[430] font-season text-[var(--landing-text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
Max tokens
</span>
</div>
<div className='flex flex-col gap-1.5'>
{data.entries.map(({ model, value }) => {
const pct = data.maxValue > 0 ? (value / data.maxValue) * 100 : 0
const color = getProviderColor(model.providerId)
return (
<Link
key={model.id}
href={model.href}
className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
<ModelLabel model={model} />
<div className='relative flex h-7 min-w-0 flex-1 items-center'>
<div
className='h-full rounded-r-[3px]'
style={{
width: `${Math.max(pct, 3)}%`,
backgroundColor: color,
opacity: 0.8,
}}
/>
<span className='ml-2.5 shrink-0 font-mono text-[var(--landing-text-muted)] text-xs'>
{formatTokenCount(value)}
</span>
</div>
</Link>
)
})}
</div>
</div>
)
}
interface ModelComparisonChartsProps {
models: CatalogModel[]
}
export function ModelComparisonCharts({ models }: ModelComparisonChartsProps) {
const comparisonModels = useMemo(() => selectComparisonModels(models), [models])
return (
<section aria-labelledby='comparison-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='comparison-heading'
className='mb-2 text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
Compare models
</h2>
<p className='font-[430] font-season text-[var(--landing-text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
Side-by-side comparison of top models across key metrics.
</p>
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='flex flex-col sm:flex-row'>
<div className='flex-1 p-6'>
<StackedCostChart models={comparisonModels} />
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)] sm:h-auto sm:w-px' />
<div className='flex-1 p-6'>
<ContextWindowChart models={comparisonModels} />
</div>
</div>
</section>
)
}

View File

@@ -3,20 +3,14 @@
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { Input } from '@/components/emcn'
import { SearchIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import {
CapabilityTags,
DetailItem,
ModelCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import { ChevronArrow, ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
import {
type CatalogModel,
type CatalogProvider,
formatPrice,
formatTokenCount,
MODEL_PROVIDERS_WITH_CATALOGS,
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
export function ModelDirectory() {
@@ -35,7 +29,7 @@ export function ModelDirectory() {
const normalizedQuery = query.trim().toLowerCase()
const { filteredProviders, filteredDynamicProviders, visibleModelCount } = useMemo(() => {
const { filteredProviders, filteredDynamicProviders } = useMemo(() => {
const filteredProviders = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => {
const providerMatchesSearch =
normalizedQuery.length > 0 && provider.searchText.includes(normalizedQuery)
@@ -77,15 +71,9 @@ export function ModelDirectory() {
return provider.searchText.includes(normalizedQuery)
})
const visibleModelCount = filteredProviders.reduce(
(count, provider) => count + provider.models.length,
0
)
return {
filteredProviders,
filteredDynamicProviders,
visibleModelCount,
}
}, [activeProviderId, normalizedQuery])
@@ -93,170 +81,143 @@ export function ModelDirectory() {
return (
<div>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
<div className='relative max-w-[560px] flex-1'>
<SearchIcon
<div className='mb-6 flex flex-col gap-4 px-6 sm:flex-row sm:items-center'>
<div className='relative max-w-[480px] flex-1'>
<svg
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[var(--landing-text-muted)]'
/>
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[#555]'
fill='none'
stroke='currentColor'
strokeWidth={2}
viewBox='0 0 24 24'
>
<circle cx={11} cy={11} r={8} />
<path d='m21 21-4.35-4.35' />
</svg>
<Input
type='search'
placeholder='Search models, providers, capabilities, or pricing details'
placeholder='Search models, providers, or capabilities'
value={query}
onChange={(event) => setQuery(event.target.value)}
className='h-11 border-[var(--landing-border)] bg-[var(--landing-bg-card)] pl-10 text-[var(--landing-text)] placeholder:text-[var(--landing-text-muted)]'
className='pl-9'
aria-label='Search AI models'
/>
</div>
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
Showing {visibleModelCount.toLocaleString('en-US')} of{' '}
{TOTAL_MODELS.toLocaleString('en-US')} models
{activeProviderId ? ' in one provider' : ''}.
</p>
</div>
<div className='mb-10 flex flex-wrap gap-2'>
<FilterButton
isActive={activeProviderId === null}
<div className='mb-6 flex flex-wrap gap-2 px-6'>
<button
type='button'
onClick={() => setActiveProviderId(null)}
label={`All providers (${MODEL_PROVIDERS_WITH_CATALOGS.length})`}
/>
className={`rounded-[5px] border px-[9px] py-0.5 text-[13.5px] transition-colors ${
activeProviderId === null
? 'border-[var(--landing-border-strong)] bg-[var(--landing-bg-elevated)] text-[var(--landing-text)]'
: 'border-[var(--landing-border-strong)] text-[var(--landing-text)] hover:bg-[var(--landing-bg-elevated)]'
}`}
>
All
</button>
{providerOptions.map((provider) => (
<FilterButton
<button
key={provider.id}
isActive={activeProviderId === provider.id}
type='button'
onClick={() =>
setActiveProviderId(activeProviderId === provider.id ? null : provider.id)
}
label={`${provider.name} (${provider.count})`}
/>
className={`rounded-[5px] border px-[9px] py-0.5 text-[13.5px] transition-colors ${
activeProviderId === provider.id
? 'border-[var(--landing-border-strong)] bg-[var(--landing-bg-elevated)] text-[var(--landing-text)]'
: 'border-[var(--landing-border-strong)] text-[var(--landing-text)] hover:bg-[var(--landing-bg-elevated)]'
}`}
>
{provider.name}
</button>
))}
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{!hasResults ? (
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-6 py-12 text-center'>
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>No matches found</h3>
<p className='mt-2 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
<div className='px-6 py-12 text-center'>
<h3 className='text-[18px] text-white'>No matches found</h3>
<p className='mt-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
Try a provider name like OpenAI or Anthropic, or search for capabilities like
&nbsp;structured outputs, reasoning, or deep research.
</p>
</div>
) : (
<div className='space-y-10'>
{filteredProviders.map((provider) => (
<section
key={provider.id}
aria-labelledby={`${provider.id}-heading`}
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<div className='mb-6 flex flex-col gap-5 border-[var(--landing-border)] border-b pb-6 lg:flex-row lg:items-start lg:justify-between'>
<div className='min-w-0'>
<div className='mb-3 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div>
<p className='text-[12px] text-[var(--landing-text-muted)]'>Provider</p>
<h2
id={`${provider.id}-heading`}
className='font-[500] text-[24px] text-[var(--landing-text)]'
>
{provider.name}
</h2>
</div>
</div>
<div>
{filteredProviders.map((provider, index) => (
<section key={provider.id} aria-labelledby={`${provider.id}-heading`}>
{index > 0 && <div className='h-px w-full bg-[var(--landing-bg-elevated)]' />}
<p className='max-w-[720px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<Link
href={provider.href}
className='mt-3 inline-flex text-[#555] text-[13px] transition-colors hover:text-[var(--landing-text-muted)]'
<Link
href={provider.href}
className='group/link flex items-center gap-3 px-6 py-4 transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
<ProviderIcon
provider={provider}
className='h-8 w-8 rounded-[5px]'
iconClassName='h-4 w-4'
/>
<div className='min-w-0 flex-1'>
<h2
id={`${provider.id}-heading`}
className='text-[14px] text-white leading-snug tracking-[-0.02em]'
>
View provider page
</Link>
{provider.name}
</h2>
<p className='line-clamp-1 hidden text-[12px] text-[var(--landing-text-muted)] leading-[150%] sm:block'>
{provider.modelCount} models &middot; {provider.description}
</p>
</div>
<ChevronArrow />
</Link>
<div className='grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-3'>
<StatCard label='Models' value={provider.models.length.toString()} />
<StatCard
label='Default'
value={provider.defaultModelDisplayName || 'Dynamic'}
compact
/>
<StatCard
label='Context info'
value={provider.contextInformationAvailable ? 'Tracked' : 'Limited'}
compact
/>
</div>
</div>
<div className='mb-6'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{provider.models.map((model) => (
<ModelCard key={model.id} provider={provider} model={model} />
))}
</div>
{provider.models.map((model) => (
<ModelRow key={model.id} provider={provider} model={model} />
))}
</section>
))}
{filteredDynamicProviders.length > 0 && (
<section
aria-labelledby='dynamic-catalogs-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<div className='mb-6'>
<section aria-labelledby='dynamic-catalogs-heading'>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='px-6 pt-8 pb-6'>
<h2
id='dynamic-catalogs-heading'
className='font-[500] text-[24px] text-[var(--landing-text)]'
className='text-[18px] text-white leading-[100%] tracking-[-0.02em] lg:text-[20px]'
>
Dynamic model catalogs
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These providers are supported by Sim, but their model lists are loaded dynamically
at runtime rather than hard-coded into the public catalog.
<p className='mt-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
These providers load their model lists dynamically at runtime.
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<nav aria-label='Dynamic catalog providers' className='flex flex-col lg:flex-row'>
{filteredDynamicProviders.map((provider) => (
<article
<div
key={provider.id}
className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] p-5'
className='flex flex-1 items-center gap-3 border-[var(--landing-bg-elevated)] border-t px-6 py-4 first:border-t-0 lg:border-t-0 lg:border-l lg:first:border-l-0'
>
<div className='mb-4 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div className='min-w-0'>
<h3 className='font-[500] text-[16px] text-[var(--landing-text)]'>
{provider.name}
</h3>
<p className='text-[12px] text-[var(--landing-text-muted)]'>
{provider.id}
</p>
</div>
<ProviderIcon
provider={provider}
className='h-8 w-8 rounded-[5px]'
iconClassName='h-4 w-4'
/>
<div className='min-w-0 flex-1'>
<h3 className='text-[14px] text-white leading-snug'>{provider.name}</h3>
<p className='line-clamp-1 text-[12px] text-[var(--landing-text-muted)] leading-[150%]'>
{provider.description}
</p>
</div>
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<div className='mt-4 space-y-3 text-[13px]'>
<DetailItem
label='Default'
value={provider.defaultModelDisplayName || 'Selected at runtime'}
/>
<DetailItem label='Catalog source' value='Loaded dynamically inside Sim' />
</div>
<div className='mt-4'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
</article>
</div>
))}
</div>
</nav>
</section>
)}
</div>
@@ -265,27 +226,33 @@ export function ModelDirectory() {
)
}
function FilterButton({
isActive,
onClick,
label,
}: {
isActive: boolean
onClick: () => void
label: string
}) {
function ModelRow({ provider, model }: { provider: CatalogProvider; model: CatalogModel }) {
return (
<button
type='button'
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-[12px] transition-colors',
isActive
? 'border-[#555] bg-[#333] text-[var(--landing-text)]'
: 'border-[var(--landing-border)] bg-transparent text-[var(--landing-text-muted)] hover:border-[var(--landing-border-strong)] hover:text-[var(--landing-text)]'
)}
>
{label}
</button>
<>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<Link
href={model.href}
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
<ProviderIcon
provider={provider}
className='h-8 w-8 shrink-0 rounded-[5px]'
iconClassName='h-4 w-4'
/>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='text-[14px] text-white leading-snug tracking-[-0.02em]'>
{model.displayName}
</h3>
<p className='line-clamp-1 hidden text-[12px] text-[var(--landing-text-muted)] leading-[150%] sm:block'>
{model.id} &middot; Input {formatPrice(model.pricing.input)}/1M &middot; Output{' '}
{formatPrice(model.pricing.output)}/1M
{model.contextWindow ? ` · ${formatTokenCount(model.contextWindow)} context` : ''}
</p>
</div>
<ChevronArrow />
</Link>
</>
)
}

View File

@@ -12,7 +12,7 @@ export function Breadcrumbs({ items }: { items: Array<{ label: string; href?: st
return (
<nav
aria-label='Breadcrumb'
className='mb-10 flex flex-wrap items-center gap-2 text-[#555] text-[13px]'
className='mb-10 flex flex-wrap items-center gap-2 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'
>
{items.map((item, index) => (
<span key={`${item.label}-${index}`} className='inline-flex items-center gap-2'>
@@ -35,7 +35,7 @@ export function Breadcrumbs({ items }: { items: Array<{ label: string; href?: st
export function ProviderIcon({
provider,
className = 'h-12 w-12 rounded-2xl',
className = 'h-12 w-12 rounded-[5px]',
iconClassName = 'h-6 w-6',
}: {
provider: Pick<CatalogProvider, 'icon' | 'name'>
@@ -51,7 +51,7 @@ export function ProviderIcon({
{Icon ? (
<Icon className={iconClassName} />
) : (
<span className='font-[500] text-[14px] text-[var(--landing-text)]'>
<span className='font-[430] text-[14px] text-[var(--landing-text)]'>
{provider.name.slice(0, 2).toUpperCase()}
</span>
)}
@@ -69,12 +69,12 @@ export function StatCard({
compact?: boolean
}) {
return (
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] px-4 py-3'>
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
<div className='rounded-[5px] border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] px-4 py-3'>
<p className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{label}
</p>
<p
className={`mt-1 font-[500] text-[var(--landing-text)] ${
className={`mt-1 font-[430] text-[var(--landing-text)] ${
compact ? 'break-all text-[12px] leading-snug' : 'text-[18px]'
}`}
>
@@ -86,17 +86,49 @@ export function StatCard({
export function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div className='rounded-xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-3 py-2'>
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
<div className='rounded-[5px] border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-3 py-2'>
<p className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{label}
</p>
<p className='mt-1 break-words font-[500] text-[12px] text-[var(--landing-text)] leading-snug'>
<p className='mt-1 break-words font-[430] text-[12px] text-[var(--landing-text)] leading-snug'>
{value}
</p>
</div>
)
}
export function ChevronArrow() {
return (
<svg
className='h-3 w-3 shrink-0 text-[var(--landing-text-subtle)]'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
>
<line
x1='0'
y1='5'
x2='9'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M3.5 2L6.5 5L3.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='transition-transform duration-200 ease-out group-hover/link:translate-x-[30%]'
/>
</svg>
)
}
export function CapabilityTags({ tags }: { tags: string[] }) {
if (tags.length === 0) {
return null
@@ -116,23 +148,76 @@ export function CapabilityTags({ tags }: { tags: string[] }) {
)
}
export function FeaturedProviderCard({ provider }: { provider: CatalogProvider }) {
return (
<Link
href={provider.href}
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--landing-bg-elevated)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<ProviderIcon
provider={provider}
className='h-10 w-10 rounded-[5px]'
iconClassName='h-5 w-5'
/>
<div className='flex flex-col gap-2'>
<h3 className='text-lg text-white leading-tight tracking-[-0.01em]'>{provider.name}</h3>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{provider.description}
</p>
</div>
</Link>
)
}
export function FeaturedModelCard({
provider,
model,
}: {
provider: CatalogProvider
model: CatalogModel
}) {
return (
<Link
href={model.href}
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--landing-bg-elevated)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<ProviderIcon
provider={provider}
className='h-10 w-10 rounded-[5px]'
iconClassName='h-5 w-5'
/>
<div className='flex flex-col gap-2'>
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{provider.name}
</span>
<h3 className='text-lg text-white leading-tight tracking-[-0.01em]'>{model.displayName}</h3>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{model.summary}
</p>
</div>
</Link>
)
}
export function ProviderCard({ provider }: { provider: CatalogProvider }) {
return (
<Link
href={provider.href}
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
className='group flex h-full flex-col rounded-[5px] border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
>
<div className='mb-4 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div className='min-w-0'>
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>{provider.name}</h3>
<p className='text-[12px] text-[var(--landing-text-muted)]'>
<h3 className='font-[430] font-season text-base text-white tracking-[-0.01em]'>
{provider.name}
</h3>
<p className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{provider.modelCount} models tracked
</p>
</div>
</div>
<p className='mb-4 flex-1 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
<p className='mb-4 flex-1 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{provider.description}
</p>
@@ -165,26 +250,30 @@ export function ModelCard({
return (
<Link
href={model.href}
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
className='group flex h-full flex-col rounded-[5px] border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
>
<div className='mb-4 flex items-start gap-3'>
<ProviderIcon
provider={provider}
className='h-10 w-10 rounded-xl'
className='h-10 w-10 rounded-[5px]'
iconClassName='h-5 w-5'
/>
<div className='min-w-0 flex-1'>
{showProvider ? (
<p className='mb-1 text-[12px] text-[var(--landing-text-muted)]'>{provider.name}</p>
<p className='mb-1 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{provider.name}
</p>
) : null}
<h3 className='break-all font-[500] text-[16px] text-[var(--landing-text)] leading-snug'>
<h3 className='break-all font-[430] font-season text-base text-white leading-snug tracking-[-0.01em]'>
{model.displayName}
</h3>
<p className='mt-1 break-all text-[12px] text-[var(--landing-text-muted)]'>{model.id}</p>
<p className='mt-1 break-all font-martian-mono text-[var(--landing-text-subtle)] text-xs tracking-[0.1em]'>
{model.id}
</p>
</div>
</div>
<p className='mb-3 line-clamp-3 flex-1 text-[12px] text-[var(--landing-text-muted)] leading-relaxed'>
<p className='mb-3 line-clamp-3 flex-1 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{model.summary}
</p>

View File

@@ -0,0 +1,132 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import { getProviderColor } from '@/app/(landing)/models/components/consts'
import type { CatalogModel } from '@/app/(landing)/models/utils'
function formatShortDate(date: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}).format(new Date(date))
} catch {
return date
}
}
interface ModelTimelineChartProps {
models: CatalogModel[]
providerId: string
}
const ITEM_WIDTH = 150
export function ModelTimelineChart({ models, providerId }: ModelTimelineChartProps) {
const entries = useMemo(() => {
return models
.filter((m) => m.releaseDate !== null)
.map((m) => ({
model: m,
date: new Date(m.releaseDate as string),
dateStr: m.releaseDate as string,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime())
}, [models])
if (entries.length === 0) return null
const color = getProviderColor(providerId)
return (
<section aria-labelledby='timeline-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='timeline-heading'
className='mb-2 text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
Release timeline
</h2>
<p className='font-[430] font-season text-[var(--landing-text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
When each model was first publicly available.
</p>
</div>
<div className='overflow-x-auto px-6 pb-8'>
{/* Fixed height: top labels + line + bottom labels */}
<div
className='relative h-[140px]'
style={{ minWidth: `${entries.length * ITEM_WIDTH}px` }}
>
{/* Horizontal line — vertically centered */}
<div className='absolute top-[70px] right-0 left-0 h-px bg-[var(--landing-border-strong)]' />
{entries.map(({ model, dateStr }, i) => {
const left = i * ITEM_WIDTH + ITEM_WIDTH / 2
const isAbove = i % 2 === 0
return (
<Link
key={model.id}
href={model.href}
className='group absolute flex flex-col items-center'
style={{
left: `${left}px`,
width: `${ITEM_WIDTH}px`,
marginLeft: `${-ITEM_WIDTH / 2}px`,
top: 0,
height: '100%',
}}
>
{/* Dot — centered exactly on the line (70px - 4.5px) */}
<div
className='-translate-x-1/2 absolute top-[66px] left-1/2 h-[9px] w-[9px] rounded-full transition-[filter,transform] duration-150 group-hover:scale-150 group-hover:brightness-150'
style={{ backgroundColor: color, opacity: 0.85 }}
/>
{/* Stem + label above */}
{isAbove && (
<div className='-translate-x-1/2 absolute bottom-[74px] left-1/2 flex flex-col items-center'>
<div className='flex flex-col items-center gap-0.5 pb-1.5'>
<span className='whitespace-nowrap font-medium text-[12px] text-[var(--landing-text)] leading-none tracking-[-0.01em] transition-colors group-hover:text-white'>
{model.displayName}
</span>
<span className='whitespace-nowrap font-mono text-[10px] text-[var(--landing-text-muted)] leading-none'>
{formatShortDate(dateStr)}
</span>
</div>
<div
className='w-px'
style={{ height: '10px', backgroundColor: color, opacity: 0.2 }}
/>
</div>
)}
{/* Stem + label below */}
{!isAbove && (
<div className='-translate-x-1/2 absolute top-[75px] left-1/2 flex flex-col items-center'>
<div
className='w-px'
style={{ height: '10px', backgroundColor: color, opacity: 0.2 }}
/>
<div className='flex flex-col items-center gap-0.5 pt-1.5'>
<span className='whitespace-nowrap font-medium text-[12px] text-[var(--landing-text)] leading-none tracking-[-0.01em] transition-colors group-hover:text-white'>
{model.displayName}
</span>
<span className='whitespace-nowrap font-mono text-[10px] text-[var(--landing-text-muted)] leading-none'>
{formatShortDate(dateStr)}
</span>
</div>
</div>
)}
</Link>
)
})}
</div>
</div>
</section>
)
}

View File

@@ -1,10 +1,15 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import { ModelComparisonCharts } from '@/app/(landing)/models/components/model-comparison-charts'
import { ModelDirectory } from '@/app/(landing)/models/components/model-directory'
import { ModelCard, ProviderCard } from '@/app/(landing)/models/components/model-primitives'
import {
FeaturedModelCard,
FeaturedProviderCard,
} from '@/app/(landing)/models/components/model-primitives'
import {
ALL_CATALOG_MODELS,
getPricingBounds,
MODEL_CATALOG_PROVIDERS,
MODEL_PROVIDERS_WITH_CATALOGS,
@@ -17,24 +22,29 @@ const baseUrl = getBaseUrl()
const faqItems = [
{
question: 'What is the Sim AI models directory?',
question: 'Which AI models are best for building agents and automated workflows?',
answer:
'The Sim AI models directory is a public catalog of the language models and providers tracked inside Sim. It shows provider coverage, model IDs, pricing per one million tokens, context windows, and supported capabilities such as reasoning controls, structured outputs, and deep research.',
'The most important factors for agent tasks are reliable tool use (function calling), a large enough context window to track conversation history and tool outputs, and consistent instruction following. In Sim, OpenAI GPT-4.1, Anthropic Claude Sonnet, and Google Gemini 2.5 Pro are popular choices — each supports tool use, structured outputs, and context windows of 128K tokens or more. For cost-sensitive or high-throughput agents, Groq and Cerebras offer significantly faster inference at lower cost.',
},
{
question: 'Can I compare models from multiple providers in one place?',
question: 'What does context window size mean when running an AI agent?',
answer:
'Yes. This page organizes every tracked model by provider and lets you search across providers, model names, and capabilities. You can quickly compare OpenAI, Anthropic, Google, xAI, Mistral, Groq, Cerebras, Fireworks, Bedrock, and more from a single directory.',
'The context window is the total number of tokens a model can process in a single call, including your system prompt, conversation history, tool call results, and any documents you pass in. For agents running multi-step tasks, context fills up quickly — each tool result and each retrieved document adds tokens. A 128K-token context window fits roughly 300 pages of text; models like Gemini 2.5 Pro support up to 1M tokens, enough to hold an entire codebase in a single pass.',
},
{
question: 'Are these model prices shown per million tokens?',
question: 'Are model prices shown per million tokens?',
answer:
'Yes. Input, cached input, and output prices on this page are shown per one million tokens based on the provider metadata tracked in Sim.',
'Yes. Input, cached input, and output prices are all listed per one million tokens, matching how providers bill through their APIs. For agents that chain multiple calls, costs compound quickly — an agent completing 100 turns at 10K tokens each consumes roughly 1M tokens per session. Cached input pricing applies when a provider supports prompt caching, where a repeated prefix like a system prompt is billed at a reduced rate.',
},
{
question: 'Does Sim support providers with dynamic model catalogs too?',
question: 'Which AI models support tool use and function calling?',
answer:
'Yes. Some providers such as OpenRouter, Fireworks, Ollama, and vLLM load their model lists dynamically at runtime. Those providers are still shown here even when their full public model list is not hard-coded into the catalog.',
'Tool use — also called function calling — lets an agent invoke external APIs, query databases, run code, or take any action you define. In Sim, all first-party models from OpenAI, Anthropic, Google, Mistral, Groq, Cerebras, and xAI support tool use. Look for the Tool Use capability tag on any model card in this directory to confirm support.',
},
{
question: 'How do I add a model to a Sim agent workflow?',
answer:
'Open any workflow in Sim, add an Agent block, and select your provider and model from the model picker inside that block. Every model listed in this directory is available in the Agent block. Swapping models takes one click and does not affect the rest of your workflow, making it straightforward to test different models on the same task without rebuilding anything.',
},
]
@@ -82,15 +92,15 @@ export default function ModelsPage() {
const flatModels = MODEL_CATALOG_PROVIDERS.flatMap((provider) =>
provider.models.map((model) => ({ provider, model }))
)
const featuredProviders = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 6)
const featuredModels = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
provider.featuredModels[0] ? [{ provider, model: provider.featuredModels[0] }] : []
).slice(0, 6)
const heroProviders = ['openai', 'anthropic', 'azure-openai', 'google', 'bedrock']
.map((providerId) => MODEL_CATALOG_PROVIDERS.find((provider) => provider.id === providerId))
.filter(
(provider): provider is (typeof MODEL_CATALOG_PROVIDERS)[number] => provider !== undefined
const featuredProviderOrder = ['anthropic', 'openai', 'google']
const featuredProviders = featuredProviderOrder
.map((id) => MODEL_PROVIDERS_WITH_CATALOGS.find((p) => p.id === id))
.filter((p): p is (typeof MODEL_PROVIDERS_WITH_CATALOGS)[number] => p !== undefined)
const featuredModels = featuredProviders
.map((provider) =>
provider.featuredModels[0] ? { provider, model: provider.featuredModels[0] } : null
)
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
@@ -159,135 +169,89 @@ export default function ModelsPage() {
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-16 sm:px-8 md:px-12'>
<section aria-labelledby='models-heading' className='mb-14'>
<div className='max-w-[840px]'>
<p className='mb-3 text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.16em]'>
Public model directory
</p>
<section className='bg-[var(--landing-bg)]'>
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<Badge
variant='blue'
size='md'
dot
className='mb-5 bg-white/10 font-season text-white uppercase tracking-[0.02em]'
>
Models
</Badge>
<div className='flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between'>
<h1
id='models-heading'
className='text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'
className='text-balance text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
>
Browse AI models by provider, pricing, and capabilities
Models
</h1>
<p className='mt-5 max-w-[760px] text-[18px] text-[var(--landing-text-muted)] leading-relaxed'>
Explore every model tracked in Sim across providers like{' '}
{heroProviders.map((provider, index, allProviders) => {
const Icon = provider.icon
return (
<span key={provider.id}>
<span className='inline-flex items-center gap-1 whitespace-nowrap align-[0.02em]'>
{Icon ? (
<span
aria-hidden='true'
className='relative top-[0.02em] inline-flex shrink-0 text-[var(--landing-text)]'
>
<Icon className='h-[0.82em] w-[0.82em]' />
</span>
) : null}
<span>{provider.name}</span>
</span>
{index < allProviders.length - 1 ? ', ' : ''}
</span>
)
})}
{
' and more. Compare model IDs, token pricing, context windows, and features such as reasoning, structured outputs, and deep research from one clean catalog.'
}
<p className='font-[430] font-season text-[var(--landing-text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
Browse {TOTAL_MODELS} AI models across {TOTAL_MODEL_PROVIDERS} providers. Compare
pricing, context windows, and capabilities.
</p>
</div>
</div>
<div className='mt-8 flex flex-wrap gap-3'>
<a
href='https://sim.ai'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Start building free
</a>
<Link
href='/integrations'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Explore integrations
</Link>
</div>
</section>
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
<section aria-labelledby='providers-heading' className='mb-16'>
<div className='mb-6'>
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
{featuredProviders.length > 0 && (
<>
<nav aria-label='Featured providers' className='flex flex-col sm:flex-row'>
{featuredProviders.map((provider) => (
<FeaturedProviderCard key={provider.id} provider={provider} />
))}
</nav>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
{featuredModels.length > 0 && (
<>
<nav aria-label='Featured models' className='flex flex-col sm:flex-row'>
{featuredModels.map(({ provider, model }) => (
<FeaturedModelCard key={model.id} provider={provider} model={model} />
))}
</nav>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
<ModelComparisonCharts models={ALL_CATALOG_MODELS} />
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<section aria-labelledby='all-models-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='all-models-heading'
className='mb-2 text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
All models
</h2>
</div>
<ModelDirectory />
</section>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<section aria-labelledby='faq-heading' className='px-6 py-10'>
<h2
id='providers-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
id='faq-heading'
className='mb-8 text-[20px] text-white leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
Browse by provider
Frequently asked questions
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Each provider has its own generated SEO page with model lineup details, featured
models, provider FAQs, and internal links to individual model pages.
</p>
</div>
<div>
<LandingFAQ faqs={faqItems} />
</div>
</section>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{featuredProviders.map((provider) => (
<ProviderCard key={provider.id} provider={provider} />
))}
</div>
</section>
<section aria-labelledby='featured-models-heading' className='mb-16'>
<div className='mb-6'>
<h2
id='featured-models-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Featured model pages
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These pages are generated directly from the model registry and target high-intent
search queries around pricing, context windows, and model capabilities.
</p>
</div>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{featuredModels.map(({ provider, model }) => (
<ModelCard key={model.id} provider={provider} model={model} showProvider />
))}
</div>
</section>
<section aria-labelledby='all-models-heading'>
<div className='mb-6'>
<h2
id='all-models-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
All models
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Search the full catalog by provider, model ID, or capability. Use it to compare
providers, sanity-check pricing, and quickly understand which models fit the workflow
you&apos;re building. All pricing is shown per one million tokens using the metadata
currently tracked in Sim.
</p>
</div>
<ModelDirectory />
</section>
<section
aria-labelledby='faq-heading'
className='mt-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2 id='faq-heading' className='font-[500] text-[28px] text-[var(--landing-text)]'>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqItems} />
</div>
</section>
</div>
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
</section>
</>
)
}

View File

@@ -13,12 +13,6 @@ const PROVIDER_PREFIXES: Record<string, string[]> = {
vllm: ['vllm/'],
}
const PROVIDER_NAME_OVERRIDES: Record<string, string> = {
deepseek: 'DeepSeek',
vllm: 'vLLM',
xai: 'xAI',
}
const TOKEN_REPLACEMENTS: Record<string, string> = {
ai: 'AI',
aws: 'AWS',
@@ -108,6 +102,7 @@ export interface CatalogModel {
providerName: string
providerSlug: string
contextWindow: number | null
releaseDate: string | null
pricing: PricingInfo
capabilities: ModelCapabilities
capabilityTags: string[]
@@ -126,6 +121,8 @@ export interface CatalogProvider {
defaultModel: string
defaultModelDisplayName: string
icon?: ComponentType<{ className?: string }>
color?: string
isReseller: boolean
contextInformationAvailable: boolean
providerCapabilityTags: string[]
modelCount: number
@@ -418,10 +415,6 @@ function buildModelSummary(
return parts.filter(Boolean).join(' ')
}
function getProviderDisplayName(providerId: string, providerName: string): string {
return PROVIDER_NAME_OVERRIDES[providerId] ?? providerName
}
function computeModelRelevanceScore(model: CatalogModel): number {
return (
(model.capabilities.reasoningEffort ? 10 : 0) +
@@ -438,7 +431,7 @@ function compareModelsByRelevance(a: CatalogModel, b: CatalogModel): number {
const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
const providerSlug = slugify(provider.id)
const providerDisplayName = getProviderDisplayName(provider.id, provider.name)
const providerDisplayName = provider.name
const providerCapabilityTags = buildCapabilityTags(provider.capabilities ?? {})
const models: CatalogModel[] = provider.models.map((model) => {
@@ -464,6 +457,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
providerName: providerDisplayName,
providerSlug,
contextWindow: model.contextWindow ?? null,
releaseDate: model.releaseDate ?? null,
pricing: model.pricing,
capabilities: mergedCapabilities,
capabilityTags,
@@ -507,6 +501,8 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
defaultModel: provider.defaultModel,
defaultModelDisplayName,
icon: provider.icon,
color: provider.color,
isReseller: provider.isReseller ?? false,
contextInformationAvailable: provider.contextInformationAvailable !== false,
providerCapabilityTags,
modelCount: models.length,
@@ -514,7 +510,6 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
featuredModels,
searchText: [
provider.name,
providerDisplayName,
provider.id,
provider.description,
provider.defaultModel,
@@ -631,7 +626,13 @@ export function buildProviderFaqs(provider: CatalogProvider): CatalogFaq[] {
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return [
const toolUseModels = provider.models.filter(
(m) =>
m.capabilities.toolUsageControl !== undefined ||
provider.providerCapabilityTags.includes('Tool Use')
)
const faqs: CatalogFaq[] = [
{
question: `What ${provider.name} models are available in Sim?`,
answer: `Sim currently tracks ${provider.modelCount} ${provider.name} model${provider.modelCount === 1 ? '' : 's'} including ${provider.models
@@ -662,10 +663,27 @@ export function buildProviderFaqs(provider: CatalogProvider): CatalogFaq[] {
: `Context window details are not fully available for every ${provider.name} model in the public catalog.`,
},
]
if (toolUseModels.length > 0) {
faqs.push({
question: `Which ${provider.name} models support tool use and function calling in Sim?`,
answer:
toolUseModels.length === provider.modelCount
? `All ${provider.name} models in Sim support tool use and function calling, allowing agents to invoke external APIs, query databases, and run custom actions.`
: `${toolUseModels
.slice(0, 5)
.map((m) => m.displayName)
.join(
', '
)}${toolUseModels.length > 5 ? ', and others' : ''} support tool use and function calling in Sim, enabling agents to invoke external APIs and run custom actions.`,
})
}
return faqs
}
export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel): CatalogFaq[] {
return [
const faqs: CatalogFaq[] = [
{
question: `What is ${model.displayName}?`,
answer: `${model.displayName} is a ${provider.name} model available in Sim. ${model.summary}`,
@@ -677,17 +695,26 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
{
question: `What is the context window for ${model.displayName}?`,
answer: model.contextWindow
? `${model.displayName} supports a listed context window of ${formatTokenCount(model.contextWindow)} tokens in Sim.`
? `${model.displayName} supports a context window of ${formatTokenCount(model.contextWindow)} tokens in Sim. In an agent workflow, this determines how much conversation history, tool outputs, and retrieved documents the model can hold in a single call.`
: `A public context window value is not currently tracked for ${model.displayName}.`,
},
{
question: `What capabilities does ${model.displayName} support?`,
answer:
model.capabilityTags.length > 0
? `${model.displayName} supports ${model.capabilityTags.join(', ')}.`
: `${model.displayName} is available in Sim, but no extra public capability flags are currently tracked for this model.`,
? `${model.displayName} supports the following capabilities in Sim: ${model.capabilityTags.join(', ')}.`
: `${model.displayName} supports standard text generation in Sim. No additional capability flags such as tool use or structured outputs are currently tracked for this model.`,
},
]
if (model.bestFor) {
faqs.push({
question: `What is ${model.displayName} best used for?`,
answer: `${model.bestFor} When used in a Sim workflow, it can be selected in any Agent block from the model picker.`,
})
}
return faqs
}
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {

View File

@@ -220,6 +220,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
/* Brand & state */
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #1a5cf6;
--warning: #ea580c;
@@ -375,6 +376,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
/* Brand & state */
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #4b83f7;
--warning: #ff6600;

View File

@@ -1,8 +1,11 @@
import { createLogger } from '@sim/logger'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('SocketTokenAPI')
export async function POST() {
if (isAuthDisabled) {
return NextResponse.json({ token: 'anonymous-socket-token' })
@@ -19,7 +22,23 @@ export async function POST() {
}
return NextResponse.json({ token: response.token })
} catch {
} catch (error) {
// better-auth's sessionMiddleware throws APIError("UNAUTHORIZED") with no message
// when the session is missing/expired — surface this as a 401, not a 500.
if (
error instanceof Error &&
('statusCode' in error || 'status' in error) &&
((error as Record<string, unknown>).statusCode === 401 ||
(error as Record<string, unknown>).status === 'UNAUTHORIZED')
) {
logger.warn('Socket token request with invalid/expired session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
logger.error('Failed to generate socket token', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
}

View File

@@ -140,6 +140,10 @@ vi.mock('@/lib/workflows/streaming/streaming', () => ({
createStreamingResponse: vi.fn().mockImplementation(async () => createMockStream()),
}))
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
executeWorkflow: vi.fn().mockResolvedValue({ success: true, output: {} }),
}))
vi.mock('@/lib/core/utils/sse', () => ({
SSE_HEADERS: {
'Content-Type': 'text/event-stream',
@@ -410,14 +414,7 @@ describe('Chat Identifier API Route', () => {
expect(createStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
workflow: expect.objectContaining({
id: 'workflow-id',
userId: 'user-id',
}),
input: expect.objectContaining({
input: 'Hello world',
conversationId: 'conv-123',
}),
executeFn: expect.any(Function),
streamConfig: expect.objectContaining({
isSecureMode: true,
workflowTriggerType: 'chat',
@@ -494,9 +491,9 @@ describe('Chat Identifier API Route', () => {
expect(createStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
input: 'Hello world',
conversationId: 'test-conversation-123',
executeFn: expect.any(Function),
streamConfig: expect.objectContaining({
workflowTriggerType: 'chat',
}),
})
)
@@ -510,9 +507,7 @@ describe('Chat Identifier API Route', () => {
expect(createStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
input: 'Hello world',
}),
executeFn: expect.any(Function),
})
)
})

View File

@@ -199,6 +199,7 @@ export async function POST(
}
const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming')
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
const { SSE_HEADERS } = await import('@/lib/core/utils/sse')
const workflowInput: any = { input, conversationId }
@@ -252,15 +253,31 @@ export async function POST(
const stream = await createStreamingResponse({
requestId,
workflow: workflowForExecution,
input: workflowInput,
executingUserId: workspaceOwnerId,
streamConfig: {
selectedOutputs,
isSecureMode: true,
workflowTriggerType: 'chat',
},
executionId,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
executeWorkflow(
workflowForExecution,
requestId,
workflowInput,
workspaceOwnerId,
{
enabled: true,
selectedOutputs,
isSecureMode: true,
workflowTriggerType: 'chat',
onStream,
onBlockComplete,
skipLoggingComplete: true,
abortSignal,
executionMode: 'stream',
},
executionId
),
})
const streamResponse = new NextResponse(stream, {

View File

@@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
@@ -216,45 +217,39 @@ export async function POST(
...formData, // Spread form fields at top level for convenience
}
// Execute workflow using streaming (for consistency with chat)
const stream = await createStreamingResponse({
requestId,
workflow: workflowForExecution,
input: workflowInput,
executingUserId: workspaceOwnerId,
streamConfig: {
selectedOutputs: [],
isSecureMode: true,
workflowTriggerType: 'api', // Use 'api' type since form is similar
workflowTriggerType: 'api',
},
executionId,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
executeWorkflow(
workflowForExecution,
requestId,
workflowInput,
workspaceOwnerId,
{
enabled: true,
selectedOutputs: [],
isSecureMode: true,
workflowTriggerType: 'api',
onStream,
onBlockComplete,
skipLoggingComplete: true,
abortSignal,
executionMode: 'sync',
},
executionId
),
})
// For forms, we don't stream back - we wait for completion and return success
// Consume the stream to wait for completion
const reader = stream.getReader()
let lastOutput: any = null
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// Parse SSE data if present
const text = new TextDecoder().decode(value)
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.type === 'complete' || data.output) {
lastOutput = data.output || data
}
} catch {
// Ignore parse errors
}
}
}
while (!(await reader.read()).done) {
/* drain to let the workflow run to completion */
}
} finally {
reader.releaseLock()

View File

@@ -15,14 +15,6 @@ import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('KnowledgeBaseAPI')
/**
* Schema for creating a knowledge base
*
* Chunking config units:
* - maxSize: tokens (1 token ≈ 4 characters)
* - minSize: characters
* - overlap: tokens (1 token ≈ 4 characters)
*/
const CreateKnowledgeBaseSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
@@ -31,12 +23,20 @@ const CreateKnowledgeBaseSchema = z.object({
embeddingDimension: z.literal(1536).default(1536),
chunkingConfig: z
.object({
/** Maximum chunk size in tokens (1 token ≈ 4 characters) */
maxSize: z.number().min(100).max(4000).default(1024),
/** Minimum chunk size in characters */
minSize: z.number().min(1).max(2000).default(100),
/** Overlap between chunks in tokens (1 token ≈ 4 characters) */
overlap: z.number().min(0).max(500).default(200),
strategy: z
.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token'])
.default('auto')
.optional(),
strategyOptions: z
.object({
pattern: z.string().max(500).optional(),
separators: z.array(z.string()).optional(),
recipe: z.enum(['plain', 'markdown', 'code']).optional(),
})
.optional(),
})
.default({
maxSize: 1024,
@@ -45,13 +45,31 @@ const CreateKnowledgeBaseSchema = z.object({
})
.refine(
(data) => {
// Convert maxSize from tokens to characters for comparison (1 token ≈ 4 chars)
const maxSizeInChars = data.maxSize * 4
return data.minSize < maxSizeInChars
},
{
message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)',
}
)
.refine(
(data) => {
return data.overlap < data.maxSize
},
{
message: 'Overlap must be less than max chunk size',
}
)
.refine(
(data) => {
if (data.strategy === 'regex' && !data.strategyOptions?.pattern) {
return false
}
return true
},
{
message: 'Regex pattern is required when using the regex chunking strategy',
}
),
})

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
import { subscription, workflowExecutionLogs, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -26,38 +26,19 @@ export async function GET(request: NextRequest) {
const retentionDate = new Date()
retentionDate.setDate(retentionDate.getDate() - Number(env.FREE_PLAN_LOG_RETENTION_DAYS || '7'))
const freeUsers = await db
.select({ userId: user.id })
.from(user)
const freeWorkspacesSubquery = db
.select({ id: workspace.id })
.from(workspace)
.leftJoin(
subscription,
and(
eq(user.id, subscription.referenceId),
eq(subscription.referenceId, workspace.billedAccountUserId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
sqlIsPaid(subscription.plan)
)
)
.where(isNull(subscription.id))
if (freeUsers.length === 0) {
logger.info('No free users found for log cleanup')
return NextResponse.json({ message: 'No free users found for cleanup' })
}
const freeUserIds = freeUsers.map((u) => u.userId)
const workspacesQuery = await db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.billedAccountUserId, freeUserIds))
if (workspacesQuery.length === 0) {
logger.info('No workspaces found for free users')
return NextResponse.json({ message: 'No workspaces found for cleanup' })
}
const workspaceIds = workspacesQuery.map((w) => w.id)
const results = {
enhancedLogs: {
total: 0,
@@ -83,7 +64,7 @@ export async function GET(request: NextRequest) {
let batchesProcessed = 0
let hasMoreLogs = true
logger.info(`Starting enhanced logs cleanup for ${workspaceIds.length} workspaces`)
logger.info('Starting enhanced logs cleanup for free-plan workspaces')
while (hasMoreLogs && batchesProcessed < MAX_BATCHES) {
const oldEnhancedLogs = await db
@@ -105,8 +86,8 @@ export async function GET(request: NextRequest) {
.from(workflowExecutionLogs)
.where(
and(
inArray(workflowExecutionLogs.workspaceId, workspaceIds),
lt(workflowExecutionLogs.createdAt, retentionDate)
inArray(workflowExecutionLogs.workspaceId, freeWorkspacesSubquery),
lt(workflowExecutionLogs.startedAt, retentionDate)
)
)
.limit(BATCH_SIZE)

View File

@@ -0,0 +1,213 @@
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { HEX_COLOR_REGEX } from '@/lib/branding'
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
const logger = createLogger('WhitelabelAPI')
const updateWhitelabelSchema = z.object({
brandName: z
.string()
.trim()
.max(64, 'Brand name must be 64 characters or fewer')
.nullable()
.optional(),
logoUrl: z.string().min(1).nullable().optional(),
wordmarkUrl: z.string().min(1).nullable().optional(),
primaryColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)')
.nullable()
.optional(),
primaryHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color')
.nullable()
.optional(),
accentColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color')
.nullable()
.optional(),
accentHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color')
.nullable()
.optional(),
supportEmail: z
.string()
.email('Support email must be a valid email address')
.nullable()
.optional(),
documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(),
termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(),
privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(),
hidePoweredBySim: z.boolean().optional(),
})
/**
* GET /api/organizations/[id]/whitelabel
* Returns the organization's whitelabel settings.
* Accessible by any member of the organization.
*/
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const [memberEntry] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const [org] = await db
.select({ whitelabelSettings: organization.whitelabelSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
return NextResponse.json({
success: true,
data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
})
} catch (error) {
logger.error('Failed to get whitelabel settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT /api/organizations/[id]/whitelabel
* Updates the organization's whitelabel settings.
* Requires enterprise plan and owner/admin role.
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const body = await request.json()
const parsed = updateWhitelabelSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
{ status: 400 }
)
}
const [memberEntry] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden - Only organization owners and admins can update whitelabel settings' },
{ status: 403 }
)
}
const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)
if (!hasEnterprisePlan) {
return NextResponse.json(
{ error: 'Whitelabeling is available on Enterprise plans only' },
{ status: 403 }
)
}
const [currentOrg] = await db
.select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!currentOrg) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {}
const incoming = parsed.data
const merged: OrganizationWhitelabelSettings = { ...current }
for (const key of Object.keys(incoming) as Array<keyof typeof incoming>) {
const value = incoming[key]
if (value === null) {
delete merged[key as keyof OrganizationWhitelabelSettings]
} else if (value !== undefined) {
;(merged as Record<string, unknown>)[key] = value
}
}
const [updated] = await db
.update(organization)
.set({ whitelabelSettings: merged, updatedAt: new Date() })
.where(eq(organization.id, organizationId))
.returning({ whitelabelSettings: organization.whitelabelSettings })
if (!updated) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: currentOrg.name,
description: 'Updated organization whitelabel settings',
metadata: { changes: Object.keys(incoming) },
request,
})
return NextResponse.json({
success: true,
data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
})
} catch (error) {
logger.error('Failed to update whitelabel settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,19 +1,44 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { AuthType } from '@/lib/auth/hybrid'
import { getJobQueue, shouldUseBullMQ } from '@/lib/core/async-jobs'
import { createBullMQJobData } from '@/lib/core/bullmq'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
import { setExecutionMeta } from '@/lib/execution/event-buffer'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import type { ResumeExecutionPayload } from '@/background/resume-execution'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { SerializedSnapshot } from '@/executor/types'
const logger = createLogger('WorkflowResumeAPI')
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function getStoredSnapshotConfig(pausedExecution: { executionSnapshot: unknown }): {
executionMode?: 'sync' | 'stream' | 'async'
selectedOutputs?: string[]
} {
try {
const serialized = pausedExecution.executionSnapshot as SerializedSnapshot
const snapshot = ExecutionSnapshot.fromJSON(serialized.snapshot)
return {
executionMode: snapshot.metadata.executionMode,
selectedOutputs: snapshot.selectedOutputs,
}
} catch {
return {}
}
}
export async function POST(
request: NextRequest,
{
@@ -24,7 +49,6 @@ export async function POST(
) {
const { workflowId, executionId, contextId } = await params
// Allow resume from dashboard without requiring deployment
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
@@ -74,12 +98,12 @@ export async function POST(
const preprocessResult = await preprocessExecution({
workflowId,
userId,
triggerType: 'manual', // Resume is a manual trigger
triggerType: 'manual',
executionId: resumeExecutionId,
requestId,
checkRateLimit: false, // Manual triggers bypass rate limits
checkDeployment: false, // Resuming existing execution, deployment already checked
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
checkRateLimit: false,
checkDeployment: false,
skipUsageLimits: true,
useAuthenticatedUserAsActor: isPersonalApiKeyCaller,
workspaceId: workflow.workspaceId || undefined,
})
@@ -142,8 +166,35 @@ export async function POST(
}
const isApiCaller = access.auth?.authType === AuthType.API_KEY
const snapshotConfig = isApiCaller ? getStoredSnapshotConfig(enqueueResult.pausedExecution) : {}
const executionMode = isApiCaller ? (snapshotConfig.executionMode ?? 'sync') : undefined
if (isApiCaller) {
if (isApiCaller && executionMode === 'stream') {
const stream = await createStreamingResponse({
requestId,
streamConfig: {
selectedOutputs: snapshotConfig.selectedOutputs,
timeoutMs: preprocessResult.executionTimeout?.sync,
},
executionId: enqueueResult.resumeExecutionId,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
PauseResumeManager.startResumeExecution({
...resumeArgs,
onStream,
onBlockComplete,
abortSignal,
}),
})
return new NextResponse(stream, {
headers: {
...SSE_HEADERS,
'X-Execution-Id': enqueueResult.resumeExecutionId,
},
})
}
if (isApiCaller && executionMode === 'sync') {
const result = await PauseResumeManager.startResumeExecution(resumeArgs)
return NextResponse.json({
@@ -162,6 +213,68 @@ export async function POST(
})
}
if (isApiCaller && executionMode === 'async') {
const resumePayload: ResumeExecutionPayload = {
resumeEntryId: enqueueResult.resumeEntryId,
resumeExecutionId: enqueueResult.resumeExecutionId,
pausedExecutionId: enqueueResult.pausedExecution.id,
contextId: enqueueResult.contextId,
resumeInput: enqueueResult.resumeInput,
userId: enqueueResult.userId,
workflowId,
parentExecutionId: executionId,
}
let jobId: string
try {
const useBullMQ = shouldUseBullMQ()
if (useBullMQ) {
jobId = await enqueueWorkspaceDispatch({
id: enqueueResult.resumeExecutionId,
workspaceId: workflow.workspaceId,
lane: 'runtime',
queueName: 'resume-execution',
bullmqJobName: 'resume-execution',
bullmqPayload: createBullMQJobData(resumePayload, {
workflowId,
userId,
}),
metadata: { workflowId, userId },
})
} else {
const jobQueue = await getJobQueue()
jobId = await jobQueue.enqueue('resume-execution', resumePayload, {
metadata: { workflowId, workspaceId: workflow.workspaceId, userId },
})
}
logger.info('Enqueued async resume execution', {
jobId,
resumeExecutionId: enqueueResult.resumeExecutionId,
})
} catch (dispatchError) {
logger.error('Failed to dispatch async resume execution', {
error: dispatchError instanceof Error ? dispatchError.message : String(dispatchError),
resumeExecutionId: enqueueResult.resumeExecutionId,
})
return NextResponse.json(
{ error: 'Failed to queue resume execution. Please try again.' },
{ status: 503 }
)
}
return NextResponse.json(
{
success: true,
async: true,
jobId,
executionId: enqueueResult.resumeExecutionId,
message: 'Resume execution queued',
statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`,
},
{ status: 202 }
)
}
PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => {
logger.error('Failed to start resume execution', {
workflowId,
@@ -200,7 +313,6 @@ export async function GET(
) {
const { workflowId, executionId, contextId } = await params
// Allow access without API key for browser-based UI (same as parent execution endpoint)
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })

View File

@@ -8,6 +8,7 @@ import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
import { getWorkflowById } from '@/lib/workflows/utils'
import {
executeJobInline,
executeScheduleJob,
@@ -115,7 +116,6 @@ export async function GET(request: NextRequest) {
}
try {
const { getWorkflowById } = await import('@/lib/workflows/utils')
const resolvedWorkflow = schedule.workflowId
? await getWorkflowById(schedule.workflowId)
: null

View File

@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { hasExceededCostLimit } from '@/lib/billing/core/subscription'
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { recordUsage } from '@/lib/billing/core/usage-log'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -110,11 +110,14 @@ export async function POST(request: NextRequest) {
}
}
if (billingUserId && isBillingEnabled) {
const exceeded = await hasExceededCostLimit(billingUserId)
if (exceeded) {
if (billingUserId) {
const usageCheck = await checkServerSideUsageLimits(billingUserId)
if (usageCheck.isExceeded) {
return NextResponse.json(
{ error: 'Usage limit exceeded. Please upgrade your plan to continue.' },
{
error:
usageCheck.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
},
{ status: 402 }
)
}

View File

@@ -51,7 +51,9 @@ export async function POST(request: NextRequest) {
const command = new DescribeAlarmsCommand({
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
AlarmTypes: validatedData.alarmType
? [validatedData.alarmType as AlarmType]
: (['MetricAlarm', 'CompositeAlarm'] as AlarmType[]),
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
})

View File

@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
}))
}
} catch {
throw new Error('Invalid dimensions JSON')
return NextResponse.json({ error: 'Invalid dimensions JSON format' }, { status: 400 })
}
}

View File

@@ -0,0 +1,136 @@
import {
CloudWatchClient,
PutMetricDataCommand,
type StandardUnit,
} from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
const logger = createLogger('CloudWatchPutMetricData')
const VALID_UNITS = [
'Seconds',
'Microseconds',
'Milliseconds',
'Bytes',
'Kilobytes',
'Megabytes',
'Gigabytes',
'Terabytes',
'Bits',
'Kilobits',
'Megabits',
'Gigabits',
'Terabits',
'Percent',
'Count',
'Bytes/Second',
'Kilobytes/Second',
'Megabytes/Second',
'Gigabytes/Second',
'Terabytes/Second',
'Bits/Second',
'Kilobits/Second',
'Megabits/Second',
'Gigabits/Second',
'Terabits/Second',
'Count/Second',
'None',
] as const
const PutMetricDataSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
namespace: z.string().min(1, 'Namespace is required'),
metricName: z.string().min(1, 'Metric name is required'),
value: z.number({ coerce: true }).refine((v) => Number.isFinite(v), {
message: 'Metric value must be a finite number',
}),
unit: z.enum(VALID_UNITS).optional(),
dimensions: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true
try {
const parsed = JSON.parse(val)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
} catch {
return false
}
},
{ message: 'dimensions must be a valid JSON object string' }
),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = PutMetricDataSchema.parse(body)
const client = new CloudWatchClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})
const timestamp = new Date()
const dimensions: { Name: string; Value: string }[] = []
if (validatedData.dimensions) {
const parsed = JSON.parse(validatedData.dimensions)
for (const [name, value] of Object.entries(parsed)) {
dimensions.push({ Name: name, Value: String(value) })
}
}
const command = new PutMetricDataCommand({
Namespace: validatedData.namespace,
MetricData: [
{
MetricName: validatedData.metricName,
Value: validatedData.value,
Timestamp: timestamp,
...(validatedData.unit && { Unit: validatedData.unit as StandardUnit }),
...(dimensions.length > 0 && { Dimensions: dimensions }),
},
],
})
await client.send(command)
return NextResponse.json({
success: true,
output: {
success: true,
namespace: validatedData.namespace,
metricName: validatedData.metricName,
value: validatedData.value,
unit: validatedData.unit ?? 'None',
timestamp: timestamp.toISOString(),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to publish CloudWatch metric'
logger.error('PutMetricData failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,115 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import {
getJiraCloudId,
getJsmFormsApiBaseUrl,
getJsmHeaders,
parseJsmErrorMessage,
} from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmIssueFormsAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!issueIdOrKey) {
logger.error('Missing issueIdOrKey in request')
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
}
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
if (!issueIdOrKeyValidation.isValid) {
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
}
const baseUrl = getJsmFormsApiBaseUrl(cloudId)
const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form`
logger.info('Fetching issue forms from:', { url, issueIdOrKey })
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM Forms API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
const forms = Array.isArray(data) ? data : (data.values ?? data.forms ?? [])
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
forms: forms.map((form: Record<string, unknown>) => ({
id: form.id ?? null,
name: form.name ?? null,
updated: form.updated ?? null,
submitted: form.submitted ?? false,
lock: form.lock ?? false,
internal: form.internal ?? null,
formTemplateId: (form.formTemplate as Record<string, unknown>)?.id ?? null,
})),
total: forms.length,
},
})
} catch (error) {
logger.error('Error fetching issue forms:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,117 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import {
getJiraCloudId,
getJsmFormsApiBaseUrl,
getJsmHeaders,
parseJsmErrorMessage,
} from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmFormStructureAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey, formId } = body
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!projectIdOrKey) {
logger.error('Missing projectIdOrKey in request')
return NextResponse.json({ error: 'Project ID or key is required' }, { status: 400 })
}
if (!formId) {
logger.error('Missing formId in request')
return NextResponse.json({ error: 'Form ID is required' }, { status: 400 })
}
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const projectIdOrKeyValidation = validateJiraIssueKey(projectIdOrKey, 'projectIdOrKey')
if (!projectIdOrKeyValidation.isValid) {
return NextResponse.json({ error: projectIdOrKeyValidation.error }, { status: 400 })
}
const formIdValidation = validateJiraCloudId(formId, 'formId')
if (!formIdValidation.isValid) {
return NextResponse.json({ error: formIdValidation.error }, { status: 400 })
}
const baseUrl = getJsmFormsApiBaseUrl(cloudId)
const url = `${baseUrl}/project/${encodeURIComponent(projectIdOrKey)}/form/${encodeURIComponent(formId)}`
logger.info('Fetching form template from:', { url, projectIdOrKey, formId })
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM Forms API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
projectIdOrKey,
formId,
design: data.design ?? null,
updated: data.updated ?? null,
publish: data.publish ?? null,
},
})
} catch (error) {
logger.error('Error fetching form structure:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,115 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import {
getJiraCloudId,
getJsmFormsApiBaseUrl,
getJsmHeaders,
parseJsmErrorMessage,
} from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmFormTemplatesAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey } = body
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!projectIdOrKey) {
logger.error('Missing projectIdOrKey in request')
return NextResponse.json({ error: 'Project ID or key is required' }, { status: 400 })
}
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const projectIdOrKeyValidation = validateJiraIssueKey(projectIdOrKey, 'projectIdOrKey')
if (!projectIdOrKeyValidation.isValid) {
return NextResponse.json({ error: projectIdOrKeyValidation.error }, { status: 400 })
}
const baseUrl = getJsmFormsApiBaseUrl(cloudId)
const url = `${baseUrl}/project/${encodeURIComponent(projectIdOrKey)}/form`
logger.info('Fetching project form templates from:', { url, projectIdOrKey })
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM Forms API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
const templates = Array.isArray(data) ? data : (data.values ?? [])
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
projectIdOrKey,
templates: templates.map((template: Record<string, unknown>) => ({
id: template.id ?? null,
name: template.name ?? null,
updated: template.updated ?? null,
issueCreateIssueTypeIds: template.issueCreateIssueTypeIds ?? [],
issueCreateRequestTypeIds: template.issueCreateRequestTypeIds ?? [],
portalRequestTypeIds: template.portalRequestTypeIds ?? [],
recommendedIssueRequestTypeIds: template.recommendedIssueRequestTypeIds ?? [],
})),
total: templates.length,
},
})
} catch (error) {
logger.error('Error fetching form templates:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -12,6 +12,20 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestAPI')
function parseJsmErrorMessage(status: number, statusText: string, errorText: string): string {
try {
const errorData = JSON.parse(errorText)
if (errorData.errorMessage) {
return `JSM API error: ${errorData.errorMessage}`
}
} catch {
if (errorText) {
return `JSM API error: ${errorText}`
}
}
return `JSM API error: ${status} ${statusText}`
}
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
@@ -31,6 +45,7 @@ export async function POST(request: NextRequest) {
description,
raiseOnBehalfOf,
requestFieldValues,
formAnswers,
requestParticipants,
channel,
expand,
@@ -55,7 +70,7 @@ export async function POST(request: NextRequest) {
const baseUrl = getJsmApiBaseUrl(cloudId)
const isCreateOperation = serviceDeskId && requestTypeId && summary
const isCreateOperation = serviceDeskId && requestTypeId && (summary || formAnswers)
if (isCreateOperation) {
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
@@ -69,15 +84,30 @@ export async function POST(request: NextRequest) {
}
const url = `${baseUrl}/request`
logger.info('Creating request at:', url)
logger.info('Creating request at:', { url, serviceDeskId, requestTypeId })
const requestBody: Record<string, unknown> = {
serviceDeskId,
requestTypeId,
requestFieldValues: requestFieldValues || {
summary,
...(description && { description }),
},
}
if (summary || description || requestFieldValues) {
const fieldValues =
requestFieldValues && typeof requestFieldValues === 'object'
? {
...(!requestFieldValues.summary && summary ? { summary } : {}),
...(!requestFieldValues.description && description ? { description } : {}),
...requestFieldValues,
}
: {
...(summary && { summary }),
...(description && { description }),
}
requestBody.requestFieldValues = fieldValues
}
if (formAnswers && typeof formAnswers === 'object') {
requestBody.form = { answers: formAnswers }
}
if (raiseOnBehalfOf) {
@@ -112,7 +142,10 @@ export async function POST(request: NextRequest) {
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}
@@ -178,7 +211,10 @@ export async function POST(request: NextRequest) {
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}

View File

@@ -20,9 +20,6 @@ export async function GET(
const { provider } = await params
const requestId = generateShortId()
const LOCK_KEY = `${provider}-polling-lock`
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, `${provider} webhook polling`)
if (authError) return authError
@@ -31,29 +28,38 @@ export async function GET(
return NextResponse.json({ error: `Unknown polling provider: ${provider}` }, { status: 404 })
}
lockValue = requestId
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
const LOCK_KEY = `${provider}-polling-lock`
let lockValue: string | undefined
try {
lockValue = requestId
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollProvider(provider)
return NextResponse.json({
success: true,
message: `${provider} polling completed`,
requestId,
status: 'completed',
...results,
})
} finally {
if (lockValue) {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
}
const results = await pollProvider(provider)
return NextResponse.json({
success: true,
message: `${provider} polling completed`,
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during ${provider} polling (${requestId}):`, error)
return NextResponse.json(
@@ -65,9 +71,5 @@ export async function GET(
},
{ status: 500 }
)
} finally {
if (lockValue) {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
}
}

View File

@@ -39,6 +39,7 @@ import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
@@ -213,6 +214,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
requestId,
correlation,
callChain,
executionMode: 'async',
}
try {
@@ -789,6 +791,7 @@ async function handleExecutePost(
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
executionMode: 'sync',
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
@@ -1012,6 +1015,7 @@ async function handleExecutePost(
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
executionMode: 'sync',
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
@@ -1051,17 +1055,15 @@ async function handleExecutePost(
cachedWorkflowData?.blocks || {}
)
const streamVariables = cachedWorkflowData?.variables ?? (workflow as any).variables
const streamWorkflow = {
id: workflow.id,
userId: actorUserId,
workspaceId,
isDeployed: workflow.isDeployed,
variables: streamVariables,
}
const stream = await createStreamingResponse({
requestId,
workflow: {
id: workflow.id,
userId: actorUserId,
workspaceId,
isDeployed: workflow.isDeployed,
variables: streamVariables,
},
input: processedInput,
executingUserId: actorUserId,
streamConfig: {
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: false,
@@ -1071,6 +1073,27 @@ async function handleExecutePost(
timeoutMs: preprocessResult.executionTimeout?.sync,
},
executionId,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
executeWorkflow(
streamWorkflow,
requestId,
processedInput,
actorUserId,
{
enabled: true,
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: false,
workflowTriggerType: triggerType === 'chat' ? 'chat' : 'api',
onStream,
onBlockComplete,
skipLoggingComplete: true,
includeFileBase64,
base64MaxBytes,
abortSignal,
executionMode: 'stream',
},
executionId
),
})
return new NextResponse(stream, {
@@ -1310,6 +1333,7 @@ async function handleExecutePost(
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
executionMode: 'sync',
}
const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}

View File

@@ -36,6 +36,8 @@ export interface AddResourceDropdownProps {
existingKeys: Set<string>
onAdd: (resource: MothershipResource) => void
onSwitch?: (resourceId: string) => void
/** Resource types to hide from the dropdown (e.g. `['folder', 'task']`). */
excludeTypes?: readonly MothershipResourceType[]
}
export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown }
@@ -47,7 +49,8 @@ interface AvailableItemsByType {
export function useAvailableResources(
workspaceId: string,
existingKeys: Set<string>
existingKeys: Set<string>,
excludeTypes?: readonly MothershipResourceType[]
): AvailableItemsByType[] {
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
@@ -56,8 +59,9 @@ export function useAvailableResources(
const { data: folders = [] } = useFolders(workspaceId)
const { data: tasks = [] } = useTasks(workspaceId)
return useMemo(
() => [
return useMemo(() => {
const excluded = new Set<MothershipResourceType>(excludeTypes ?? [])
const groups: AvailableItemsByType[] = [
{
type: 'workflow' as const,
items: workflows.map((w) => ({
@@ -107,9 +111,9 @@ export function useAvailableResources(
isOpen: existingKeys.has(`task:${t.id}`),
})),
},
],
[workflows, folders, tables, files, knowledgeBases, tasks, existingKeys]
)
]
return groups.filter((g) => !excluded.has(g.type))
}, [workflows, folders, tables, files, knowledgeBases, tasks, existingKeys, excludeTypes])
}
export function AddResourceDropdown({
@@ -117,11 +121,12 @@ export function AddResourceDropdown({
existingKeys,
onAdd,
onSwitch,
excludeTypes,
}: AddResourceDropdownProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const available = useAvailableResources(workspaceId, existingKeys)
const available = useAvailableResources(workspaceId, existingKeys, excludeTypes)
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
@@ -162,9 +167,9 @@ export function AddResourceDropdown({
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
if (filtered.length > 0 && filtered[activeIndex]) {
e.preventDefault()
const { type, item } = filtered[activeIndex]
select({ type, id: item.id, title: item.name }, item.isOpen)
}

View File

@@ -10,7 +10,7 @@ import {
import { Button, Tooltip } from '@/components/emcn'
import { Columns3, Eye, PanelLeft, Pencil } from '@/components/emcn/icons'
import { isEphemeralResource } from '@/lib/copilot/resource-extraction'
import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
@@ -38,6 +38,62 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const EDGE_ZONE = 40
const SCROLL_SPEED = 8
const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const
/**
* Returns the id of the nearest resource to `idx` that is in `filter`
* (or any resource if `filter` is null). Returns undefined if nothing qualifies.
*/
function findNearestId(
resources: MothershipResource[],
idx: number,
filter: Set<string> | null
): string | undefined {
for (let offset = 1; offset < resources.length; offset++) {
for (const candidate of [idx + offset, idx - offset]) {
const r = resources[candidate]
if (r && (!filter || filter.has(r.id))) return r.id
}
}
return undefined
}
/**
* Builds an offscreen drag image showing all selected tabs side-by-side, so the
* cursor visibly carries every tab in the multi-selection. The element is
* appended to the document and removed on the next tick after the browser has
* snapshotted it.
*/
function buildMultiDragImage(
scrollNode: HTMLElement | null,
selected: MothershipResource[]
): HTMLElement | null {
if (!scrollNode || selected.length === 0) return null
const container = document.createElement('div')
container.style.position = 'fixed'
container.style.top = '-10000px'
container.style.left = '-10000px'
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.gap = '6px'
container.style.padding = '4px'
container.style.pointerEvents = 'none'
let appendedAny = false
for (const r of selected) {
const original = scrollNode.querySelector<HTMLElement>(
`[data-resource-tab-id="${CSS.escape(r.id)}"]`
)
if (!original) continue
const clone = original.cloneNode(true) as HTMLElement
clone.style.opacity = '0.95'
container.appendChild(clone)
appendedAny = true
}
if (!appendedAny) return null
document.body.appendChild(container)
return container
}
const PREVIEW_MODE_ICONS = {
editor: Columns3,
split: Eye,
@@ -125,8 +181,19 @@ export function ResourceTabs({
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null)
const [draggedIdx, setDraggedIdx] = useState<number | null>(null)
const [dropGapIdx, setDropGapIdx] = useState<number | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const dragStartIdx = useRef<number | null>(null)
const autoScrollRaf = useRef<number | null>(null)
const anchorIdRef = useRef<string | null>(null)
const prevChatIdRef = useRef(chatId)
// Reset selection when switching chats — component instance persists across
// chat switches so stale IDs would otherwise carry over.
if (prevChatIdRef.current !== chatId) {
prevChatIdRef.current = chatId
setSelectedIds(new Set())
anchorIdRef.current = null
}
const existingKeys = useMemo(
() => new Set(resources.map((r) => `${r.type}:${r.id}`)),
@@ -143,34 +210,129 @@ export function ResourceTabs({
[chatId, onAddResource]
)
const handleTabClick = useCallback(
(e: React.MouseEvent, idx: number) => {
const resource = resources[idx]
if (!resource) return
// Shift+click: contiguous range from anchor
if (e.shiftKey) {
// Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar)
const anchorId = anchorIdRef.current ?? activeId
const anchorIdx = anchorId ? resources.findIndex((r) => r.id === anchorId) : -1
if (anchorIdx !== -1) {
const start = Math.min(anchorIdx, idx)
const end = Math.max(anchorIdx, idx)
const next = new Set<string>()
for (let i = start; i <= end; i++) next.add(resources[i].id)
setSelectedIds(next)
onSelect(resource.id)
return
}
}
// Cmd/Ctrl+click: toggle individual tab in/out of selection
if (e.metaKey || e.ctrlKey) {
const wasSelected = selectedIds.has(resource.id)
if (wasSelected) {
const next = new Set(selectedIds)
next.delete(resource.id)
setSelectedIds(next)
// Only switch active if we just deselected the currently-active tab
if (activeId === resource.id) {
const fallback =
findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null)
if (fallback) onSelect(fallback)
}
} else {
setSelectedIds((prev) => new Set(prev).add(resource.id))
onSelect(resource.id)
}
if (!anchorIdRef.current) anchorIdRef.current = resource.id
return
}
// Plain click: single-select
anchorIdRef.current = resource.id
setSelectedIds(new Set([resource.id]))
onSelect(resource.id)
},
[resources, onSelect, selectedIds, activeId]
)
const handleRemove = useCallback(
(e: React.MouseEvent, resource: MothershipResource) => {
e.stopPropagation()
if (!chatId) return
if (!isEphemeralResource(resource)) {
removeResource.mutate({ chatId, resourceType: resource.type, resourceId: resource.id })
const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1
const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource]
// Update parent state immediately for all targets
for (const r of targets) {
onRemoveResource(r.type, r.id)
}
// Clear stale selection and anchor for all removed targets
const removedIds = new Set(targets.map((r) => r.id))
setSelectedIds((prev) => {
const next = new Set(prev)
for (const id of removedIds) next.delete(id)
return next
})
if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) {
anchorIdRef.current = null
}
// Serialize mutations so each onMutate sees the cache updated by the prior
// one. Continue on individual failures so remaining removals still fire.
const persistable = targets.filter((r) => !isEphemeralResource(r))
if (persistable.length > 0) {
void (async () => {
for (const r of persistable) {
try {
await removeResource.mutateAsync({
chatId,
resourceType: r.type,
resourceId: r.id,
})
} catch {
// Individual failure — the mutation's onError already rolled back
// this resource in cache. Remaining removals continue.
}
}
})()
}
onRemoveResource(resource.type, resource.id)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chatId, onRemoveResource]
[chatId, onRemoveResource, resources, selectedIds]
)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
const resource = resources[idx]
if (!resource) return
const selected = resources.filter((r) => selectedIds.has(r.id))
const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id)
if (isMultiDrag) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected))
const dragImage = buildMultiDragImage(scrollNodeRef.current, selected)
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 16, 16)
setTimeout(() => dragImage.remove(), 0)
}
// Skip dragStartIdx so internal reorder is disabled for multi-select drags
dragStartIdx.current = null
setDraggedIdx(null)
return
}
dragStartIdx.current = idx
setDraggedIdx(idx)
e.dataTransfer.effectAllowed = 'copyMove'
e.dataTransfer.setData('text/plain', String(idx))
const resource = resources[idx]
if (resource) {
e.dataTransfer.setData(
SIM_RESOURCE_DRAG_TYPE,
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
)
}
e.dataTransfer.setData(
SIM_RESOURCE_DRAG_TYPE,
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
)
},
[resources]
[resources, selectedIds]
)
const stopAutoScroll = useCallback(() => {
@@ -308,6 +470,7 @@ export function ResourceTabs({
const isActive = activeId === resource.id
const isHovered = hoveredTabId === resource.id
const isDragging = draggedIdx === idx
const isSelected = selectedIds.has(resource.id) && selectedIds.size > 1
const showGapBefore =
dropGapIdx === idx &&
draggedIdx !== null &&
@@ -329,22 +492,24 @@ export function ResourceTabs({
<Button
variant='subtle'
draggable
data-resource-tab-id={resource.id}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragLeave={handleDragLeave}
onDragEnd={handleDragEnd}
onMouseDown={(e) => {
if (e.button === 1 && chatId) {
if (e.button === 1) {
e.preventDefault()
handleRemove(e, resource)
if (chatId) handleRemove(e, resource)
}
}}
onClick={() => onSelect(resource.id)}
onClick={(e) => handleTabClick(e, idx)}
onMouseEnter={() => setHoveredTabId(resource.id)}
onMouseLeave={() => setHoveredTabId(null)}
className={cn(
'group relative shrink-0 bg-transparent px-2 py-1 pr-[22px] text-caption transition-opacity duration-150',
isActive && 'bg-[var(--surface-4)]',
isSelected && !isActive && 'bg-[var(--surface-3)]',
isDragging && 'opacity-30'
)}
>
@@ -394,6 +559,7 @@ export function ResourceTabs({
existingKeys={existingKeys}
onAdd={handleAdd}
onSwitch={onSelect}
excludeTypes={ADD_RESOURCE_EXCLUDED_TYPES}
/>
)}
</div>

View File

@@ -39,6 +39,7 @@ import {
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSpeechToText } from '@/hooks/use-speech-to-text'
import type { ChatContext } from '@/stores/panel'
@@ -120,6 +121,7 @@ export function UserInput({
onEnterWhileEmpty,
}: UserInputProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { navigateToSettings } = useSettingsNavigation()
const { data: workflowsById = {} } = useWorkflowMap(workspaceId)
const { data: session } = useSession()
const [value, setValue] = useState(defaultValue)
@@ -239,12 +241,19 @@ export function UserInput({
valueRef.current = newVal
}, [])
const handleUsageLimitExceeded = useCallback(() => {
navigateToSettings({ section: 'subscription' })
}, [navigateToSettings])
const {
isListening,
isSupported: isSttSupported,
toggleListening: rawToggle,
resetTranscript,
} = useSpeechToText({ onTranscript: handleTranscript })
} = useSpeechToText({
onTranscript: handleTranscript,
onUsageLimitExceeded: handleUsageLimitExceeded,
})
const toggleListening = useCallback(() => {
if (!isListening) {

View File

@@ -263,7 +263,8 @@ export function AddDocumentsModal({
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
</span>
<span className='text-[var(--text-tertiary)] text-xs'>
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB
each)
</span>
</div>
</Button>

View File

@@ -9,6 +9,8 @@ import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Button,
Combobox,
type ComboboxOption,
Input,
Label,
Modal,
@@ -18,6 +20,7 @@ import {
ModalHeader,
Textarea,
} from '@/components/emcn'
import type { StrategyOptions } from '@/lib/chunkers/types'
import { cn } from '@/lib/core/utils/cn'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
@@ -35,6 +38,20 @@ interface CreateBaseModalProps {
onOpenChange: (open: boolean) => void
}
const STRATEGY_OPTIONS = [
{ value: 'auto', label: 'Auto (detect from content)' },
{ value: 'text', label: 'Text (word boundary splitting)' },
{ value: 'recursive', label: 'Recursive (configurable separators)' },
{ value: 'sentence', label: 'Sentence' },
{ value: 'token', label: 'Token (fixed-size)' },
{ value: 'regex', label: 'Regex (custom pattern)' },
] as const
const STRATEGY_COMBOBOX_OPTIONS: ComboboxOption[] = STRATEGY_OPTIONS.map((o) => ({
label: o.label,
value: o.value,
}))
const FormSchema = z
.object({
name: z
@@ -43,25 +60,24 @@ const FormSchema = z
.max(100, 'Name must be less than 100 characters')
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
/** Minimum chunk size in characters */
minChunkSize: z
.number()
.min(1, 'Min chunk size must be at least 1 character')
.max(2000, 'Min chunk size must be less than 2000 characters'),
/** Maximum chunk size in tokens (1 token ≈ 4 characters) */
maxChunkSize: z
.number()
.min(100, 'Max chunk size must be at least 100 tokens')
.max(4000, 'Max chunk size must be less than 4000 tokens'),
/** Overlap between chunks in tokens */
overlapSize: z
.number()
.min(0, 'Overlap must be non-negative')
.max(500, 'Overlap must be less than 500 tokens'),
strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).default('auto'),
regexPattern: z.string().optional(),
customSeparators: z.string().optional(),
})
.refine(
(data) => {
// Convert maxChunkSize from tokens to characters for comparison (1 token ≈ 4 chars)
const maxChunkSizeInChars = data.maxChunkSize * 4
return data.minChunkSize < maxChunkSizeInChars
},
@@ -70,6 +86,27 @@ const FormSchema = z
path: ['minChunkSize'],
}
)
.refine(
(data) => {
return data.overlapSize < data.maxChunkSize
},
{
message: 'Overlap must be less than max chunk size',
path: ['overlapSize'],
}
)
.refine(
(data) => {
if (data.strategy === 'regex' && !data.regexPattern?.trim()) {
return false
}
return true
},
{
message: 'Regex pattern is required when using the regex strategy',
path: ['regexPattern'],
}
)
type FormValues = z.infer<typeof FormSchema>
@@ -124,6 +161,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
@@ -133,11 +171,15 @@ export const CreateBaseModal = memo(function CreateBaseModal({
minChunkSize: 100,
maxChunkSize: 1024,
overlapSize: 200,
strategy: 'auto',
regexPattern: '',
customSeparators: '',
},
mode: 'onSubmit',
})
const nameValue = watch('name')
const strategyValue = watch('strategy')
useEffect(() => {
if (open) {
@@ -153,6 +195,9 @@ export const CreateBaseModal = memo(function CreateBaseModal({
minChunkSize: 100,
maxChunkSize: 1024,
overlapSize: 200,
strategy: 'auto',
regexPattern: '',
customSeparators: '',
})
}
}, [open, reset])
@@ -255,6 +300,17 @@ export const CreateBaseModal = memo(function CreateBaseModal({
setSubmitStatus(null)
try {
const strategyOptions: StrategyOptions | undefined =
data.strategy === 'regex' && data.regexPattern
? { pattern: data.regexPattern }
: data.strategy === 'recursive' && data.customSeparators?.trim()
? {
separators: data.customSeparators
.split(',')
.map((s) => s.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')),
}
: undefined
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
name: data.name,
description: data.description || undefined,
@@ -263,6 +319,8 @@ export const CreateBaseModal = memo(function CreateBaseModal({
maxSize: data.maxChunkSize,
minSize: data.minChunkSize,
overlap: data.overlapSize,
...(data.strategy !== 'auto' && { strategy: data.strategy }),
...(strategyOptions && { strategyOptions }),
},
})
@@ -312,7 +370,6 @@ export const CreateBaseModal = memo(function CreateBaseModal({
<div className='space-y-3'>
<div className='flex flex-col gap-2'>
<Label htmlFor='kb-name'>Name</Label>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
@@ -403,6 +460,59 @@ export const CreateBaseModal = memo(function CreateBaseModal({
</p>
</div>
<div className='flex flex-col gap-2'>
<Label>Chunking Strategy</Label>
<Combobox
options={STRATEGY_COMBOBOX_OPTIONS}
value={strategyValue}
onChange={(value) => setValue('strategy', value as FormValues['strategy'])}
dropdownWidth='trigger'
align='start'
/>
<p className='text-[var(--text-muted)] text-xs'>
Auto detects the best strategy based on file content type.
</p>
</div>
{strategyValue === 'regex' && (
<div className='flex flex-col gap-2'>
<Label htmlFor='regexPattern'>Regex Pattern</Label>
<Input
id='regexPattern'
placeholder='e.g. \\n\\n or (?<=\\})\\s*(?=\\{)'
{...register('regexPattern')}
className={cn(errors.regexPattern && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
/>
{errors.regexPattern && (
<p className='text-[var(--text-error)] text-xs'>
{errors.regexPattern.message}
</p>
)}
<p className='text-[var(--text-muted)] text-xs'>
Text will be split at each match of this regex pattern.
</p>
</div>
)}
{strategyValue === 'recursive' && (
<div className='flex flex-col gap-2'>
<Label htmlFor='customSeparators'>Custom Separators (optional)</Label>
<Input
id='customSeparators'
placeholder='e.g. \n\n, \n, . , '
{...register('customSeparators')}
autoComplete='off'
data-form-type='other'
/>
<p className='text-[var(--text-muted)] text-xs'>
Comma-separated list of delimiters in priority order. Leave empty for default
separators.
</p>
</div>
)}
<div className='flex flex-col gap-2'>
<Label>Upload Documents</Label>
<Button
@@ -431,7 +541,8 @@ export const CreateBaseModal = memo(function CreateBaseModal({
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
</span>
<span className='text-[var(--text-tertiary)] text-xs'>
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB
each)
</span>
</div>
</Button>

View File

@@ -1,4 +1,5 @@
import { ToastProvider } from '@/components/emcn'
import { getSession } from '@/lib/auth'
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -7,31 +8,40 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding'
export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
// The organization plugin is conditionally spread so TS can't infer activeOrganizationId on the base session type.
const orgId = (session?.session as { activeOrganizationId?: string } | null)?.activeOrganizationId
const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
<BrandingProvider initialOrgSettings={initialOrgSettings}>
<ToastProvider>
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
</div>
</div>
</div>
</div>
<NavTour />
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
<NavTour />
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
</BrandingProvider>
)
}

View File

@@ -598,6 +598,24 @@ export const LogDetails = memo(function LogDetails({
{formatCost(log.cost?.output || 0)}
</span>
</div>
{(() => {
const models = (log.cost as Record<string, unknown>)?.models as
| Record<string, { toolCost?: number }>
| undefined
const totalToolCost = models
? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0)
: 0
return totalToolCost > 0 ? (
<div className='flex items-center justify-between'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Tool Usage:
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
{formatCost(totalToolCost)}
</span>
</div>
) : null
})()}
</div>
<div className='border-[var(--border)] border-t' />
@@ -626,7 +644,7 @@ export const LogDetails = memo(function LogDetails({
<div className='flex items-center justify-center rounded-md bg-[var(--surface-2)] p-2 text-center'>
<p className='font-medium text-[var(--text-subtle)] text-xs'>
Total cost includes a base execution charge of{' '}
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
{formatCost(BASE_EXECUTION_CHARGE)} plus any model and tool usage costs.
</p>
</div>
</div>

View File

@@ -59,40 +59,61 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
const addNotification = useNotificationStore((state) => state.addNotification)
const removeNotification = useNotificationStore((state) => state.removeNotification)
const { isReconnecting } = useSocket()
const reconnectingNotificationIdRef = useRef<string | null>(null)
const { isReconnecting, isRetryingWorkflowJoin } = useSocket()
const realtimeStatusNotificationIdRef = useRef<string | null>(null)
const realtimeStatusNotificationMessageRef = useRef<string | null>(null)
const isOfflineMode = hasOperationError
const realtimeStatusMessage = isReconnecting
? 'Reconnecting...'
: isRetryingWorkflowJoin
? 'Joining workflow...'
: null
const clearRealtimeStatusNotification = useCallback(() => {
if (!realtimeStatusNotificationIdRef.current) {
return
}
removeNotification(realtimeStatusNotificationIdRef.current)
realtimeStatusNotificationIdRef.current = null
realtimeStatusNotificationMessageRef.current = null
}, [removeNotification])
useEffect(() => {
if (isReconnecting && !reconnectingNotificationIdRef.current && !isOfflineMode) {
const id = addNotification({
level: 'error',
message: 'Reconnecting...',
})
reconnectingNotificationIdRef.current = id
} else if (!isReconnecting && reconnectingNotificationIdRef.current) {
removeNotification(reconnectingNotificationIdRef.current)
reconnectingNotificationIdRef.current = null
if (isOfflineMode || !realtimeStatusMessage) {
clearRealtimeStatusNotification()
return
}
return () => {
if (reconnectingNotificationIdRef.current) {
removeNotification(reconnectingNotificationIdRef.current)
reconnectingNotificationIdRef.current = null
}
if (
realtimeStatusNotificationIdRef.current &&
realtimeStatusNotificationMessageRef.current === realtimeStatusMessage
) {
return
}
}, [isReconnecting, isOfflineMode, addNotification, removeNotification])
clearRealtimeStatusNotification()
const id = addNotification({
level: 'error',
message: realtimeStatusMessage,
})
realtimeStatusNotificationIdRef.current = id
realtimeStatusNotificationMessageRef.current = realtimeStatusMessage
}, [addNotification, clearRealtimeStatusNotification, isOfflineMode, realtimeStatusMessage])
useEffect(() => {
return clearRealtimeStatusNotification
}, [clearRealtimeStatusNotification])
useEffect(() => {
if (!isOfflineMode || hasShownOfflineNotification) {
return
}
if (reconnectingNotificationIdRef.current) {
removeNotification(reconnectingNotificationIdRef.current)
reconnectingNotificationIdRef.current = null
}
clearRealtimeStatusNotification()
try {
addNotification({
@@ -107,7 +128,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
} catch (error) {
logger.error('Failed to add offline notification', { error })
}
}, [addNotification, removeNotification, hasShownOfflineNotification, isOfflineMode])
}, [addNotification, clearRealtimeStatusNotification, hasShownOfflineNotification, isOfflineMode])
const {
data: workspacePermissions,

View File

@@ -16,6 +16,7 @@ const SECTION_TITLES: Record<string, string> = {
subscription: 'Subscription',
team: 'Team',
sso: 'Single Sign-On',
whitelabeling: 'Whitelabeling',
copilot: 'Copilot Keys',
mcp: 'MCP Tools',
'custom-tools': 'Custom Tools',

View File

@@ -156,6 +156,13 @@ const AccessControl = dynamic(
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
loading: () => <SettingsSectionSkeleton />,
})
const WhitelabelingSettings = dynamic(
() =>
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
(m) => m.WhitelabelingSettings
),
{ loading: () => <SettingsSectionSkeleton />, ssr: false }
)
interface SettingsPageProps {
section: SettingsSection
@@ -198,6 +205,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{isBillingEnabled && effectiveSection === 'subscription' && <Subscription />}
{isBillingEnabled && effectiveSection === 'team' && <TeamManagement />}
{effectiveSection === 'sso' && <SSO />}
{effectiveSection === 'whitelabeling' && <WhitelabelingSettings />}
{effectiveSection === 'byok' && <BYOK />}
{effectiveSection === 'copilot' && <Copilot />}
{effectiveSection === 'mcp' && <MCP initialServerId={mcpServerId} />}

View File

@@ -387,7 +387,7 @@ export function General() {
<Tooltip.Preview
src='/tooltips/auto-connect-on-drop.mp4'
alt='Auto-connect on drop example'
loop={false}
loop={true}
/>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger'
const logger = createLogger('ProfilePictureUpload')
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg']
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml']
interface UseProfilePictureUploadProps {
onUpload?: (url: string | null) => void
@@ -27,21 +27,19 @@ export function useProfilePictureUpload({
const [isUploading, setIsUploading] = useState(false)
useEffect(() => {
if (currentImage !== previewUrl) {
if (previewRef.current && previewRef.current !== currentImage) {
URL.revokeObjectURL(previewRef.current)
previewRef.current = null
}
setPreviewUrl(currentImage || null)
if (previewRef.current && previewRef.current !== currentImage) {
URL.revokeObjectURL(previewRef.current)
previewRef.current = null
}
}, [currentImage, previewUrl])
setPreviewUrl(currentImage || null)
}, [currentImage])
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File "${file.name}" is too large. Maximum size is 5MB.`
}
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
return `File "${file.name}" is not a supported image format. Please use PNG or JPEG.`
return `File "${file.name}" is not a supported image format. Please use PNG, JPEG, or SVG.`
}
return null
}, [])
@@ -75,52 +73,59 @@ export function useProfilePictureUpload({
}
}, [])
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const validationError = validateFile(file)
if (validationError) {
onError?.(validationError)
return
}
const processFile = useCallback(
async (file: File) => {
const validationError = validateFile(file)
if (validationError) {
onError?.(validationError)
return
}
setFileName(file.name)
setFileName(file.name)
const newPreviewUrl = URL.createObjectURL(file)
const newPreviewUrl = URL.createObjectURL(file)
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
setPreviewUrl(newPreviewUrl)
previewRef.current = newPreviewUrl
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
}
setPreviewUrl(newPreviewUrl)
previewRef.current = newPreviewUrl
setIsUploading(true)
try {
const serverUrl = await uploadFileToServer(file)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(serverUrl)
onUpload?.(serverUrl)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload profile picture'
onError?.(errorMessage)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(currentImage || null)
} finally {
setIsUploading(false)
}
setIsUploading(true)
try {
const serverUrl = await uploadFileToServer(file)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(serverUrl)
onUpload?.(serverUrl)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload profile picture'
onError?.(errorMessage)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(currentImage || null)
} finally {
setIsUploading(false)
}
},
[onUpload, onError, uploadFileToServer, validateFile, currentImage]
)
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) processFile(file)
},
[processFile]
)
const handleFileDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
const file = e.dataTransfer.files[0]
if (file) processFile(file)
},
[processFile]
)
const handleRemove = useCallback(() => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
@@ -148,6 +153,7 @@ export function useProfilePictureUpload({
fileInputRef,
handleThumbnailClick,
handleFileChange,
handleFileDrop,
handleRemove,
isUploading,
}

View File

@@ -7,6 +7,7 @@ import {
Lock,
LogIn,
Mail,
Palette,
Send,
Server,
Settings,
@@ -31,6 +32,7 @@ export type SettingsSection =
| 'subscription'
| 'team'
| 'sso'
| 'whitelabeling'
| 'copilot'
| 'mcp'
| 'custom-tools'
@@ -162,6 +164,15 @@ export const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true,
selfHostedOverride: isSSOEnabled,
},
{
id: 'whitelabeling',
label: 'Whitelabeling',
icon: Palette,
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: isBillingEnabled,
},
{
id: 'admin',
label: 'Admin',

View File

@@ -38,6 +38,8 @@ const TagIcon: React.FC<{
</div>
)
const EXCLUDED_OUTPUT_TYPES = new Set(['starter', 'start_trigger', 'human_in_the_loop'] as const)
/**
* Props for the OutputSelect component
*/
@@ -121,7 +123,7 @@ export function OutputSelect({
if (blockArray.length === 0) return outputs
blockArray.forEach((block: any) => {
if (block.type === 'starter' || !block?.id || !block?.type) return
if (EXCLUDED_OUTPUT_TYPES.has(block.type) || !block?.id || !block?.type) return
const blockName =
block.name && typeof block.name === 'string'

View File

@@ -5,6 +5,7 @@ import { useViewport } from 'reactflow'
import { getUserColor } from '@/lib/workspaces/colors'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CursorPoint {
x: number
@@ -19,11 +20,16 @@ interface CursorRenderData {
}
const CursorsComponent = () => {
const { presenceUsers, currentSocketId } = useSocket()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const { currentWorkflowId, presenceUsers, currentSocketId } = useSocket()
const viewport = useViewport()
const preventZoomRef = usePreventZoom()
const cursors = useMemo<CursorRenderData[]>(() => {
if (!activeWorkflowId || currentWorkflowId !== activeWorkflowId) {
return []
}
return presenceUsers
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
.filter((user) => user.socketId !== currentSocketId)
@@ -33,7 +39,7 @@ const CursorsComponent = () => {
cursor: user.cursor,
color: getUserColor(user.userId),
}))
}, [currentSocketId, presenceUsers])
}, [activeWorkflowId, currentSocketId, currentWorkflowId, presenceUsers])
if (!cursors.length) {
return null

View File

@@ -89,7 +89,7 @@ export function VersionDescriptionModal({
return (
<>
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
<ModalContent size='md'>
<ModalContent size='lg'>
<ModalHeader>
<span>Version Description</span>
</ModalHeader>

View File

@@ -98,7 +98,7 @@ export function CredentialSelector({
)
const provider = effectiveProviderId
const isTriggerMode = subBlock.mode === 'trigger'
const isTriggerMode = subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced'
const {
data: rawCredentials = [],

View File

@@ -242,9 +242,13 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
})
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
handleEnvVarSelect(filteredEnvVars[selectedIndex])
case 'Tab':
if (e.key === 'Tab' && e.shiftKey) break
if (filteredEnvVars[selectedIndex]) {
e.preventDefault()
e.stopPropagation()
handleEnvVarSelect(filteredEnvVars[selectedIndex])
}
break
case 'Escape':
e.preventDefault()

View File

@@ -9,7 +9,7 @@ export interface HighlightContext {
highlightAll?: boolean
}
const SYSTEM_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
const SYSTEM_PREFIXES = new Set(['loop', 'parallel', 'variable'])
/**
* Formats text by highlighting block references (<...>) and environment variables ({{...}})

View File

@@ -279,9 +279,11 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
}
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
case 'Tab':
if (e.key === 'Tab' && e.shiftKey) break
if (selected && selectedIndex >= 0 && selectedIndex < flatTagList.length) {
e.preventDefault()
e.stopPropagation()
handleTagSelect(selected.tag, selected.group)
}
break

View File

@@ -145,7 +145,9 @@ export function Editor() {
if (!triggerMode) return subBlocks
return subBlocks.filter(
(subBlock) =>
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
subBlock.mode === 'trigger' ||
subBlock.mode === 'trigger-advanced' ||
subBlock.type === ('trigger-config' as SubBlockType)
)
}, [blockConfig?.subBlocks, triggerMode])

View File

@@ -102,7 +102,9 @@ export function useEditorSubblockLayout(
const subBlocksForCanonical = displayTriggerMode
? (config.subBlocks || []).filter(
(subBlock) =>
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
subBlock.mode === 'trigger' ||
subBlock.mode === 'trigger-advanced' ||
subBlock.type === ('trigger-config' as SubBlockType)
)
: config.subBlocks || []
const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical)
@@ -137,12 +139,12 @@ export function useEditorSubblockLayout(
}
// Filter by mode if specified
if (block.mode === 'trigger') {
if (block.mode === 'trigger' || block.mode === 'trigger-advanced') {
if (!displayTriggerMode) return false
}
// When in trigger mode, hide blocks that don't have mode: 'trigger'
if (displayTriggerMode && block.mode !== 'trigger') {
// When in trigger mode, hide blocks that don't have mode: 'trigger' or 'trigger-advanced'
if (displayTriggerMode && block.mode !== 'trigger' && block.mode !== 'trigger-advanced') {
return false
}

View File

@@ -534,7 +534,6 @@ const SubBlockRow = memo(function SubBlockRow({
workspaceId
)
const credentialId = dependencyValues.credential
const knowledgeBaseId = dependencyValues.knowledgeBaseId
const dropdownLabel = useMemo(() => {
@@ -576,6 +575,7 @@ const SubBlockRow = memo(function SubBlockRow({
const collectionIdValue = resolveContextValue('collectionId')
const spreadsheetIdValue = resolveContextValue('spreadsheetId')
const fileIdValue = resolveContextValue('fileId')
const credentialId = dependencyValues.credential ?? resolveContextValue('oauthCredential')
const { displayName: selectorDisplayName } = useSelectorDisplayName({
subBlock,

View File

@@ -4,6 +4,7 @@ import {
DEFAULT_LAYOUT_PADDING,
DEFAULT_VERTICAL_SPACING,
} from '@/lib/workflows/autolayout/constants'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('AutoLayoutUtils')
@@ -109,10 +110,12 @@ export async function applyAutoLayoutAndUpdateStore(
return { success: false, error: errorMessage }
}
// Update workflow store immediately with new positions
const layoutedBlocks = result.data?.layoutedBlocks || blocks
const mergedBlocks = mergeSubblockState(layoutedBlocks, workflowId)
const newWorkflowState = {
...workflowStore.getWorkflowState(),
blocks: result.data?.layoutedBlocks || blocks,
blocks: mergedBlocks,
lastSaved: Date.now(),
}
@@ -167,9 +170,10 @@ export async function applyAutoLayoutAndUpdateStore(
})
// Revert the store changes since database save failed
const revertBlocks = mergeSubblockState(blocks, workflowId)
useWorkflowStore.getState().replaceWorkflowState({
...workflowStore.getWorkflowState(),
blocks,
blocks: revertBlocks,
lastSaved: workflowStore.lastSaved,
})

View File

@@ -1153,8 +1153,10 @@ function PreviewEditorContent({
if (subBlock.type === ('trigger-config' as SubBlockType)) {
return effectiveTrigger || isPureTriggerBlock
}
if (subBlock.mode === 'trigger' && !effectiveTrigger) return false
if (effectiveTrigger && subBlock.mode !== 'trigger') return false
if ((subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') && !effectiveTrigger)
return false
if (effectiveTrigger && subBlock.mode !== 'trigger' && subBlock.mode !== 'trigger-advanced')
return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (
!isSubBlockVisibleForMode(

View File

@@ -319,11 +319,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? subBlock.mode === 'trigger' || !subBlock.mode
: subBlock.mode === 'trigger'
? subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' || !subBlock.mode
: subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced'
if (!isValidTriggerSubblock) return false
} else {
if (subBlock.mode === 'trigger') return false
if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') return false
}
/** Skip value-dependent visibility checks in lightweight mode */

View File

@@ -17,6 +17,7 @@ import {
ModalFooter,
ModalHeader,
Plus,
Skeleton,
UserPlus,
} from '@/components/emcn'
import { getDisplayPlanName, isFree } from '@/lib/billing/plan-helpers'
@@ -356,14 +357,16 @@ export function WorkspaceHeader({
}
}}
>
<div
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
style={{
backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)',
}}
>
{workspaceInitial}
</div>
{activeWorkspaceFull ? (
<div
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
>
{workspaceInitial}
</div>
) : (
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
)}
{!isCollapsed && (
<>
<span className='min-w-0 flex-1 truncate text-left font-base text-[var(--text-primary)] text-sm'>
@@ -400,14 +403,18 @@ export function WorkspaceHeader({
) : (
<>
<div className='flex items-center gap-2 px-0.5 py-0.5'>
<div
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
style={{
backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)',
}}
>
{workspaceInitial}
</div>
{activeWorkspaceFull ? (
<div
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
style={{
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
}}
>
{workspaceInitial}
</div>
) : (
<Skeleton className='h-[32px] w-[32px] flex-shrink-0 rounded-md' />
)}
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{activeWorkspace?.name || 'Loading...'}
@@ -580,12 +587,16 @@ export function WorkspaceHeader({
title={activeWorkspace?.name || 'Loading...'}
disabled
>
<div
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
style={{ backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)' }}
>
{workspaceInitial}
</div>
{activeWorkspaceFull ? (
<div
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
>
{workspaceInitial}
</div>
) : (
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
)}
{!isCollapsed && (
<>
<span className='min-w-0 flex-1 truncate text-left font-base text-[var(--text-primary)] text-sm'>

View File

@@ -83,7 +83,7 @@ import {
useImportWorkflow,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getBrandConfig } from '@/ee/whitelabeling'
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
@@ -337,7 +337,7 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item'
* @returns Sidebar with workflows panel
*/
export const Sidebar = memo(function Sidebar() {
const brand = getBrandConfig()
const brand = useOrgBrandConfig()
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string | undefined
@@ -1251,7 +1251,16 @@ export const Sidebar = memo(function Sidebar() {
tabIndex={isCollapsed ? -1 : undefined}
aria-label={brand.name}
>
{brand.logoUrl ? (
{brand.wordmarkUrl ? (
<Image
src={brand.wordmarkUrl}
alt={brand.name}
height={16}
width={80}
className='h-[16px] w-auto flex-shrink-0 object-contain object-left'
unoptimized
/>
) : brand.logoUrl ? (
<Image
src={brand.logoUrl}
alt={brand.name}

View File

@@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest'
import {
SOCKET_JOIN_RETRY_BASE_DELAY_MS,
SOCKET_JOIN_RETRY_MAX_DELAY_MS,
SocketJoinController,
} from '@/app/workspace/providers/socket-join-controller'
describe('SocketJoinController', () => {
it('blocks rejoining a deleted workflow until the desired workflow changes', () => {
const controller = new SocketJoinController()
expect(controller.setConnected(true)).toEqual([])
expect(controller.requestWorkflow('workflow-a')).toEqual([
{ type: 'join', workflowId: 'workflow-a' },
])
expect(controller.handleJoinSuccess('workflow-a')).toMatchObject({
apply: true,
ignored: false,
commands: [],
workflowId: 'workflow-a',
})
expect(controller.handleWorkflowDeleted('workflow-a')).toEqual({
shouldClearCurrent: true,
commands: [],
})
expect(controller.requestWorkflow('workflow-a')).toEqual([])
expect(controller.requestWorkflow('workflow-b')).toEqual([
{ type: 'join', workflowId: 'workflow-b' },
])
})
it('joins only the latest desired workflow after rapid A to B to C switching', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
controller.requestWorkflow('workflow-a')
controller.handleJoinSuccess('workflow-a')
expect(controller.requestWorkflow('workflow-b')).toEqual([
{ type: 'join', workflowId: 'workflow-b' },
])
expect(controller.requestWorkflow('workflow-c')).toEqual([])
expect(controller.handleJoinSuccess('workflow-b')).toMatchObject({
apply: false,
ignored: true,
workflowId: 'workflow-b',
commands: [{ type: 'join', workflowId: 'workflow-c' }],
})
expect(controller.handleJoinSuccess('workflow-c')).toMatchObject({
apply: true,
ignored: false,
workflowId: 'workflow-c',
commands: [],
})
})
it('rejoins the original workflow when a stale success lands after switching back', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
controller.requestWorkflow('workflow-a')
controller.handleJoinSuccess('workflow-a')
expect(controller.requestWorkflow('workflow-b')).toEqual([
{ type: 'join', workflowId: 'workflow-b' },
])
expect(controller.requestWorkflow('workflow-a')).toEqual([])
expect(controller.handleJoinSuccess('workflow-b')).toMatchObject({
apply: false,
ignored: true,
workflowId: 'workflow-b',
commands: [{ type: 'join', workflowId: 'workflow-a' }],
})
expect(controller.handleJoinSuccess('workflow-a')).toMatchObject({
apply: true,
ignored: false,
workflowId: 'workflow-a',
commands: [],
})
})
it('leaves the room when a late join succeeds after navigating away', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
controller.requestWorkflow('workflow-a')
controller.handleJoinSuccess('workflow-a')
expect(controller.requestWorkflow('workflow-b')).toEqual([
{ type: 'join', workflowId: 'workflow-b' },
])
expect(controller.requestWorkflow(null)).toEqual([])
expect(controller.handleJoinSuccess('workflow-b')).toMatchObject({
apply: false,
ignored: true,
workflowId: 'workflow-b',
commands: [{ type: 'leave' }],
})
})
it('preserves the last joined workflow during retryable switch failures', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
expect(controller.requestWorkflow('workflow-a')).toEqual([
{ type: 'join', workflowId: 'workflow-a' },
])
controller.handleJoinSuccess('workflow-a')
expect(controller.requestWorkflow('workflow-b')).toEqual([
{ type: 'join', workflowId: 'workflow-b' },
])
const errorResult = controller.handleJoinError({
workflowId: 'workflow-b',
retryable: true,
})
expect(errorResult.apply).toBe(false)
expect(errorResult.retryScheduled).toBe(true)
expect(errorResult.commands).toEqual([
{
type: 'schedule-retry',
workflowId: 'workflow-b',
attempt: 1,
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS,
},
])
expect(controller.getJoinedWorkflowId()).toBe('workflow-a')
expect(controller.retryJoin('workflow-b')).toEqual([{ type: 'join', workflowId: 'workflow-b' }])
})
it('uses capped exponential backoff for retryable join failures', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
controller.requestWorkflow('workflow-a')
const first = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
expect(first.commands).toEqual([
{
type: 'schedule-retry',
workflowId: 'workflow-a',
attempt: 1,
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS,
},
])
controller.retryJoin('workflow-a')
const second = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
expect(second.commands).toEqual([
{
type: 'schedule-retry',
workflowId: 'workflow-a',
attempt: 2,
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS * 2,
},
])
controller.retryJoin('workflow-a')
controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
controller.retryJoin('workflow-a')
const fourth = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
expect(fourth.commands).toEqual([
{
type: 'schedule-retry',
workflowId: 'workflow-a',
attempt: 4,
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS * 8,
},
])
controller.retryJoin('workflow-a')
const fifth = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
expect(fifth.commands).toEqual([
{
type: 'schedule-retry',
workflowId: 'workflow-a',
attempt: 5,
delayMs: SOCKET_JOIN_RETRY_MAX_DELAY_MS,
},
])
})
it('blocks a permanently failed workflow and leaves the fallback room cleanly', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
controller.requestWorkflow('workflow-a')
controller.handleJoinSuccess('workflow-a')
expect(controller.requestWorkflow('workflow-b')).toEqual([
{ type: 'join', workflowId: 'workflow-b' },
])
const errorResult = controller.handleJoinError({
workflowId: 'workflow-b',
retryable: false,
})
expect(errorResult.apply).toBe(true)
expect(errorResult.commands).toEqual([{ type: 'leave' }])
expect(controller.getJoinedWorkflowId()).toBeNull()
expect(controller.requestWorkflow('workflow-b')).toEqual([])
expect(controller.requestWorkflow('workflow-c')).toEqual([
{ type: 'join', workflowId: 'workflow-c' },
])
})
it('rejoins the desired workflow when the server session is lost', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
controller.requestWorkflow('workflow-a')
controller.handleJoinSuccess('workflow-a')
expect(controller.forceRejoinWorkflow('workflow-a')).toEqual([
{ type: 'join', workflowId: 'workflow-a' },
])
expect(controller.getJoinedWorkflowId()).toBeNull()
})
it('resolves retryable errors without workflowId against the pending join', () => {
const controller = new SocketJoinController()
controller.setConnected(true)
controller.requestWorkflow('workflow-a')
const errorResult = controller.handleJoinError({ retryable: true })
expect(errorResult.workflowId).toBe('workflow-a')
expect(errorResult.retryScheduled).toBe(true)
expect(errorResult.commands).toEqual([
{
type: 'schedule-retry',
workflowId: 'workflow-a',
attempt: 1,
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS,
},
])
})
})

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