Compare commits

...

197 Commits

Author SHA1 Message Date
Cursor Agent
fd44398dd8 Add missing knowledge-bases entry to German API reference meta.json 2026-03-10 00:44:33 +00:00
Theodore Li
898f8ce1c1 feat(exa-hosted-key): Restore exa hosted key (#3499)
Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-09 20:40:54 -04:00
Waleed
39334bdf7d fix(tables): one small tables ting (#3497) 2026-03-09 16:43:15 -07:00
Waleed
a6d3b3a9ad improvement(tables): click-to-select navigation, inline rename, column resize (#3496)
* improvement(tables): click-to-select navigation, inline rename, column resize

* fix(tables): address PR review comments

- Add doneRef guard to useInlineRename preventing Enter+blur double-fire
- Fix PATCH error handler: return 500 for non-validation errors, fix unreachable logger.error
- Stop click propagation on breadcrumb rename input

* fix(tables): add rows-affected check in renameTable service

Prevents silent no-op when tableId doesn't match any record.

* fix(tables): useMemo deps + placeholder memo initialCharacter check

- Use primitive editingId/editValue in useMemo deps instead of whole
  useInlineRename object (which creates a new ref every render)
- Add initialCharacter comparison to placeholderPropsAreEqual, matching
  the existing pattern in dataRowPropsAreEqual

* fix(tables): address round 2 review comments

- Mirror name validation (regex + max length) in PatchTableSchema so
  validateTableName failures return 400 instead of 500
- Add .returning() + rows-affected check to renameWorkspaceFile,
  matching the renameTable pattern
- Check response.ok before parsing JSON in useRenameWorkspaceFile,
  matching the useRenameTable pattern

* refactor(tables): reuse InlineRenameInput in BreadcrumbSegment

Replace duplicated inline input markup with the shared component.
Eliminates redundant useRef, useEffect, and input boilerplate.

* fix(tables): set doneRef in cancelRename to prevent blur-triggered save

Escape → cancelRename → input unmounts → blur → submitRename would
save instead of canceling. Now cancelRename sets doneRef like
submitRename does, blocking the subsequent blur handler.

* fix(tables): pointercancel cleanup + typed FileConflictError

- Add pointercancel handler to column resize to prevent listener leaks
  when system interrupts the pointer (touch-action override, etc.)
- Replace stringly-typed error.message.includes('already exists') with
  FileConflictError class for refactor-safe 409 status detection

* fix(tables): stable useCallback dep + rename shadowed variable

- Use listRename.startRename (stable ref) instead of whole listRename
  object in handleContextMenuRename deps
- Rename inner 'target' to 'origin' in arrow-key handler to avoid
  shadowing the outer HTMLElement 'target'

* fix(tables): move class below imports, stable submitRename, clear editingCell

- Move FileConflictError below import statements (import-first convention)
- Make submitRename a stable useCallback([]) by reading editingId and
  editValue through refs (matches existing onSaveRef pattern)
- Add setEditingCell(null) to handleEmptyRowClick for symmetry with
  handleCellClick

* feat(tables): persist column widths in table metadata

Column widths now survive navigation and page reloads. On resize-end,
widths are debounced (500ms) and saved to the table's metadata field
via a new PUT /api/table/[tableId]/metadata endpoint. On load, widths
are seeded from the server once via React Query.

* fix type checking for file viewer

* fix(tables): address review feedback — 4 fixes

1. headerRename.onSave now uses the fileId parameter directly instead
   of the selectedFile closure, preventing rename-wrong-file race
2. updateMetadataMutation uses ref pattern matching mutateRef/createRef
3. Type-to-enter filters non-numeric chars for number columns, non-date
   chars for date columns
4. renameValue only passed to actively-renaming ColumnHeaderMenu,
   preserving React.memo for other columns

* fix(tables): position-based gap rows, insert above/below, consistency fixes

- Fix gap row insert shifting: only shift rows when target position is
  occupied, preventing unnecessary displacement of rows below
- Switch to position-based indexing throughout (positionMap, maxPosition)
  instead of array-index for correct sparse position handling
- Add insert row above/below to context menu
- Use CellContent for pending values in PositionGapRows (matching PlaceholderRows)
- Add belowHeader selection overlay logic to PositionGapRows
- Remove unnecessary 500ms debounce on column width persistence

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

* fix cells nav w keyboard

* added preview panel for html, markdown rendering, completed table

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:37:48 -07:00
Emir Karabeg
8fd8b1a248 improvement(mothership): chat history and stability 2026-03-09 15:57:13 -07:00
Emir Karabeg
917af6d141 improvement(mothership): chat stability 2026-03-09 15:42:12 -07:00
Vikhyath Mondreti
fe5f809e1a Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot 2026-03-09 15:28:07 -07:00
Vikhyath Mondreti
2f2c2b05e8 feat(templates): landing page templates workflow states 2026-03-09 15:22:14 -07:00
Siddharth Ganesan
0c44332172 File uploads to mothership 2026-03-09 15:19:56 -07:00
Siddharth Ganesan
7c0cd36936 Fix error status 2026-03-09 14:30:46 -07:00
Siddharth Ganesan
2788c68e45 Tool results 2026-03-09 14:24:44 -07:00
Siddharth Ganesan
cf9cc0377d Fix tool call persistence in chat 2026-03-09 14:24:44 -07:00
Emir Karabeg
a091149da4 improvement(mothership): worklfow resource 2026-03-09 14:20:59 -07:00
Waleed
64cedfcff7 fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates (#3471)
* fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates

- Add useThrottledValue hook (100ms trailing-edge throttle) to gate DOM re-renders during streaming across all chat surfaces
- Replace 100ms setInterval scroll polling with ResizeObserver-based auto-scroll, programmatic scroll timestamp tracking, and nested [data-scrollable] region handling
- Extract processContentBuffer from inline content handler for cleaner code organization in copilot SSE handlers
- Add RAF-based update batching (50ms max interval) to floating chat and home chat streaming paths
- Add useProgressiveList hook for progressive rendering of long conversation histories via requestAnimationFrame

Made-with: Cursor

* ack PR comments

* fix search modal

* more comments

* ack comments

* count

* ack comments

* ack comment
2026-03-09 13:27:33 -07:00
Vikhyath Mondreti
15db69231f fix tests 2026-03-09 12:22:40 -07:00
Vikhyath Mondreti
7b43091984 Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot 2026-03-09 12:13:34 -07:00
Emir Karabeg
48f280427e feat(mothership): resource viewer 2026-03-09 12:07:06 -07:00
Vikhyath Mondreti
1430eb66de fix(landing): wire agent input to mothership 2026-03-09 11:58:16 -07:00
Siddharth Ganesan
2ace7252f9 Store tool call results 2026-03-09 11:35:20 -07:00
Siddharth Ganesan
bcdfc85ccb Tool updates 2026-03-09 11:28:22 -07:00
Vikhyath Mondreti
e921448bf2 fix(selections): more nested folder inaccuracies 2026-03-09 11:17:43 -07:00
Vikhyath Mondreti
71d8e227bd improvement(folder-selection): folder deselection + selection order should match visual 2026-03-09 11:00:22 -07:00
Siddharth Ganesan
4593a8a471 Table tools 2026-03-09 10:21:12 -07:00
Emir Karabeg
301fdb94ff improvement(tables): multi-select and efficiencies 2026-03-09 10:07:21 -07:00
Emir Karabeg
4afc3bbff8 improvement: logs 2026-03-09 09:13:01 -07:00
waleed
76981c356f update schedule creation ui and run lint 2026-03-09 02:18:43 -07:00
Waleed
4c562c8e04 feat(tables): column operations, row ordering, V1 API (#3488)
* feat(tables): add column operations, row ordering, V1 columns API, and OpenAPI spec

Adds column rename/delete/type change/constraint updates to the tables module,
row ordering via position column, UI metadata schema, V1 public API for column
operations with rate limiting and audit logging, and OpenAPI documentation.

Key changes:
- Service-layer column operations with validation (name pattern, type compatibility, unique/required constraints)
- Position column on user_table_rows with composite index for efficient ordering
- V1 /api/v1/tables/{tableId}/columns endpoint (POST/PATCH/DELETE) with rate limiting and audit
- Shared Zod schemas extracted to table/utils.ts using COLUMN_TYPES constant
- Targeted React Query invalidation (row vs schema mutations) with consistent onSettled usage
- OpenAPI 3.1.0 spec for columns endpoint with code samples
- Position field added to all row response mappings for consistency
- Sort fallback to position ordering when buildSortClause returns null

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

* fix(tables): use specific error prefixes instead of broad "Cannot" match

Prevents internal TypeErrors (e.g. "Cannot read properties of undefined")
from leaking as 400 responses. Now matches only domain-specific errors:
"Cannot delete the last column" and "Cannot set column".

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

* fix(tables): reject Infinity and NaN in number type compatibility check

Number.isFinite rejects Infinity, -Infinity, and NaN, preventing
non-finite values from passing column type validation.

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

* fix(tables): invalidate table list on row create/delete for stale rowCount

Row create and delete mutations now invalidate the table list cache since
it includes a computed rowCount. Row updates (which don't change count)
continue to only invalidate row queries.

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

* fix(tables): add column name length check, deduplicate name gen, reset pagination on clear

- Add MAX_COLUMN_NAME_LENGTH validation to addTableColumn (was missing,
  renameColumn already had it)
- Extract generateColumnName helper to eliminate triplicated logic across
  handleAddColumn, handleInsertColumnLeft, handleInsertColumnRight
- Reset pagination to page 0 when clearing sort/filter to prevent showing
  empty pages after narrowing filters are removed

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

* fix: hoist tableId above try block in V1 columns route, add detail invalidation to invalidateRowCount

- V1 columns route: `tableId` was declared inside `try` but referenced in
  `catch` logger.error, causing undefined in error logs. Hoisted `await params`
  above try in all three handlers (POST, PATCH, DELETE).
- invalidateRowCount: added `tableKeys.detail(tableId)` invalidation since the
  single-table GET response includes `rowCount`, which becomes stale after
  row create/delete without this.

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

* fix: add position to all row mutation responses, remove dead filter code

- Add `position` field to POST (single + batch) and PATCH row responses
  across both internal and V1 routes, matching GET responses and OpenAPI spec.
- Remove unused `filterConfig`, `handleFilterToggle`, `handleFilterClear`,
  and `activeFilters` — dead code left over from merge conflict resolution.
  `handleFilterApply` (the one actually wired to JSX) is preserved.

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

* fix: invalidateTableSchema now also invalidates table list cache

Column add/rename/delete/update mutations now invalidate tableKeys.list()
since the list endpoint returns schema.columns for each table. Without this,
the sidebar table list would show stale column schemas until staleTime expires.

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

* fix: replace window.prompt/confirm with emcn Modal dialogs

Replace non-standard browser dialogs with proper emcn Modal components
to match the existing codebase pattern (e.g. delete table confirmation).

- Column rename: Modal with Input field + Enter key support
- Column delete: Modal with destructive confirmation

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:14:38 -07:00
Emir Karabeg
d4eb25df91 fix(files): icon 2026-03-09 02:12:27 -07:00
Emir Karabeg
ac2af53884 improvement: icons 2026-03-09 02:10:54 -07:00
Emir Karabeg
016d353baf improvement: icon, resource header options 2026-03-09 01:47:08 -07:00
Emir Karabeg
d9c1a53cad improvement(resource): layout 2026-03-09 01:20:50 -07:00
Waleed
2bdc073d7b fix(docs): use named grid lines instead of numeric column indices (#3487)
Root cause: the fumadocs grid template has 3 columns in production but
5 columns in local dev. Our CSS used `grid-column: 3 / span 2` which
targeted the wrong column in the 3-column grid, placing content in
the near-zero-width TOC column instead of the main content column.

Fix: use `grid-column: main-start / toc-end` which uses CSS named grid
lines from grid-template-areas, working regardless of column count.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:50:28 -07:00
Waleed
13d2a134d0 improvement(docs): align sidebar method badges and polish API reference styling (#3484)
* improvement(docs): align sidebar method badges and polish API reference styling

* fix(docs): revert className prop on DocsPage for CI compatibility

* fix(docs): restore oneOf schema for delete rows and use rem units in CSS

* fix(docs): replace :has() selectors with direct className for reliable prod layout

The API docs layout was intermittently narrow in production because CSS
:has(.api-page-header) selectors are unreliable in Tailwind v4 production
builds. Apply className="openapi-page" directly to DocsPage and replace
all 64 :has() selectors with .openapi-page class targeting.

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

* fix(docs): bypass TypeScript check for className prop on DocsPage

Use spread with type assertion to pass className to DocsPage, working
around a CI type resolution issue where the prop exists at runtime but
is not recognized by TypeScript in the Vercel build environment.

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

* fix(docs): use inline style tag for grid layout, revert CSS to :has() selectors

The className prop on DocsPage doesn't exist in the fumadocs-ui version
resolved on Vercel, so .openapi-page was never applied and all 64 CSS
rules broke. Revert to :has(.api-page-header) selectors for styling and
use an inline <style> tag for the critical grid-column layout override,
which is SSR'd and doesn't depend on any CSS selector matching.

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

* fix(docs): add pill styling to footer navigation method badges

The footer nav badges (POST, GET, etc.) had color from data-method rules
but lacked the structural pill styling (padding, border-radius, font-size).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:22:09 -07:00
Emir Karabeg
a61dc23d43 improvement: tables, dropdown 2026-03-09 00:17:52 -07:00
Waleed
12c1ede336 feat(files): inline file viewer with text editing (#3475)
* feat(files): add inline file viewer with text editing and create file modal

Add file preview/edit functionality to the workspace files page. Text files
(md, json, txt, yaml, etc.) open in an editable textarea with Cmd/Ctrl+S save.
PDFs render in an iframe. New file button creates empty .md files via a modal.
Uses ResourceHeader breadcrumbs and ResourceOptionsBar for save/download/delete.

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

* improvement(files): add UX polish, PR review fixes, and context menu

- Add unsaved changes guard modal (matching credentials manager pattern)
- Add delete confirmation modal for both viewer and context menu
- Add save status feedback (Save → Saving... → Saved)
- Add right-click context menu with Open, Download, Delete actions
- Add 50MB file size limit on content update API
- Add storage quota check before content updates
- Add response.ok guard on download to prevent corrupt files
- Add skeleton loading for pending file selection (prevents flicker)
- Fix updateContent in handleSave dependency array

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

* fix(files): propagate save errors and remove redundant sizeDiff

- Remove try/catch in TextEditor.handleSave so errors propagate to
  parent, which correctly shows save failure status
- Remove redundant inner sizeDiff declaration that shadowed outer scope

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

* fix(files): remove unused textareaRef

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

* fix(files): move Cmd+S to parent, add save error feedback, hide save for non-text files

- Move Cmd+S keyboard handler from TextEditor to Files so it goes
  through the parent handleSave with proper status management
- Add 'error' save status with red "Save failed" label that auto-resets
- Only show Save button for text-editable file types (md, txt, json, etc.)

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

* improvement(files): add save tooltip, deduplicate text-editable extensions

- Add Tooltip on Save button showing Cmd+S / Ctrl+S shortcut
- Export TEXT_EDITABLE_EXTENSIONS from file-viewer and reuse in files.tsx
  instead of duplicating the list inline

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

* refactor: extract isMacPlatform to shared utility

Move isMacPlatform() from global-commands-provider.tsx to
lib/core/utils/platform.ts so it can be reused by files.tsx tooltip
without duplication.

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

* refactor(files): deduplicate delete modal, use shared formatFileSize

- Extract DeleteConfirmModal component to eliminate duplicate modal
  markup between viewer and list modes
- Replace local formatFileSize with shared utility from file-utils.ts

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

* fix(files): fix a11y label lint error and remove mutation object from useCallback deps

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

* fix(files): add isDirty guard on handleSave, return proper HTTP status codes

Prevents "Saving → Saved" flash when pressing Cmd+S with no changes.
Returns 404 for file-not-found and 402 for quota-exceeded instead of 500.

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

* fix(files): reset isDirty/saveStatus on delete and discard, remove deprecated navigator.platform

- Clear isDirty and saveStatus when deleting the currently-viewed file to
  prevent spurious beforeunload prompts
- Reset saveStatus on discard to prevent stale "Save failed" when opening
  another file
- Remove deprecated navigator.platform, userAgent fallback covers all cases

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

* fix(files): prevent concurrent saves on rapid Cmd+S, add YAML MIME types

- Add saveStatus === 'saving' guard to handleSave to prevent duplicate
  concurrent PUT requests from rapid keyboard shortcuts
- Add yaml/yml MIME type mappings to getMimeTypeFromExtension

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

* refactor(files): reuse shared extension constants, parallelize cancelQueries

- Replace hand-rolled SUPPORTED_EXTENSIONS with composition from existing
  SUPPORTED_DOCUMENT/AUDIO/VIDEO_EXTENSIONS in validation.ts
- Parallelize sequential cancelQueries calls in delete mutation onMutate

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

* fix(files): guard handleCreate against duplicate calls while pending

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

* fix(files): show upload progress on the Upload button, not New file

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

* fix(files): use ref-based guard for create pending state to avoid stale closure

The uploadFile.isPending check was stale because the mutation object
is excluded from useCallback deps (per codebase convention). Using a
ref ensures the guard works correctly across rapid Enter key presses.

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

* cleanup(files): use shared icon import, remove no-op props, wrap handler in useCallback

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:07:35 -07:00
Emir Karabeg
627eaaf343 improvement: tables, favicon 2026-03-08 19:21:21 -07:00
Waleed Latif
1dbfaa4d23 style(schedules): apply linter formatting 2026-03-08 18:41:29 -07:00
Waleed Latif
4946571922 feat(schedules): add edit support with context menu for standalone jobs 2026-03-08 18:41:17 -07:00
Waleed Latif
6295fd1a11 feat(schedules): add schedule creator modal for standalone jobs
Add modal to create standalone scheduled jobs from the Schedules page.
Includes POST API endpoint, useCreateSchedule mutation hook, and full
modal with schedule type selection, timezone, lifecycle, and live preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:17:11 -07:00
Emir Karabeg
de5faa5265 improvement(tables): consolidation 2026-03-08 17:31:58 -07:00
Waleed
7d360649e9 fix(sidebar): restore drag-and-drop for workflows and folders (#3470)
* fix(sidebar): restore drag-and-drop for workflows and folders

Made-with: Cursor

* update docs, unrelated
2026-03-08 16:16:14 -07:00
Vikhyath Mondreti
1d955fc43a feat(mothership): billing (#3464)
* Billing update

* more billing improvements

* credits UI

* credit purchase safety

* progress

* ui improvements

* fix cancel sub

* fix types

* fix daily refresh for teams

* make max features differentiated

* address bugbot comments

* address greptile comments

* revert isHosted

* address more comments

* fix org refresh bar

* fix ui rounding

* fix minor rounding

* fix upgrade issue for legacy plans

* fix formatPlanName

* fix email dispay names

* fix legacy team reference bugs

* referral bonus in credits

* fix org upgrade bug

* improve logs

* respect toggle for paid users

* fix landing page pro features and usage limit checks

* fixed query and usage

* add unit test

* address more comments

* enterprise guard

* fix limits bug

* pass period start/end for overage
2026-03-08 03:37:54 -07:00
Waleed
1def94392b improvement(settings): fix mcp modal, add option to edit JSON and add Sim as an MCP client (#3467)
* improvement(settings): fix mcp modal, add option to edit JSON and add Sim as an MCP client

* added docs link in sidebar

* ack comments

* ack comments

* fixed error msg
2026-03-07 22:59:29 -08:00
Emir Karabeg
77bd2553f2 fix(resource): sorting 2026-03-07 21:26:54 -08:00
Emir Karabeg
8170488488 improvement(resource): sorting and icons 2026-03-07 21:19:04 -08:00
Waleed
0b42e26f10 fix(execution): ensure background tasks await post-execution DB status updates (#3466)
The fire-and-forget IIFE in execution-core.ts for post-execution logging could be abandoned when trigger.dev tasks exit, leaving executions permanently stuck in "running" status. Store the promise on LoggingSession so background tasks can optionally await it before returning.
2026-03-07 21:09:31 -08:00
Emir Karabeg
4b7a9b20c4 improvement(resources): segmented API 2026-03-07 20:48:08 -08:00
Waleed
76486ebcc8 feat(knowledge): add v1 knowledge base API, Obsidian/Evernote connectors, and docs (#3465)
* feat(knowledge): add v1 knowledge base API, Obsidian/Evernote connectors, and docs

- Add v1 REST API for knowledge bases (CRUD, document management, vector search)
- Add Obsidian and Evernote knowledge base connectors
- Add file type validation to v1 file and document upload endpoints
- Update OpenAPI spec with knowledge base endpoints and schemas
- Add connectors documentation page
- Apply query hook formatting improvements

* fix(knowledge): address PR review feedback

- Remove validateFileType from v1/files route (general file upload, not document-only)
- Reject tag filters when searching multiple KBs (tag defs are KB-specific)
- Cache tag definitions to avoid duplicate getDocumentTagDefinitions call
- Fix Obsidian connector silent empty results when syncContext is undefined

* improvement(connectors): add syncContext to getDocument, clean up caching

- Update docs to say 20+ connectors
- Add syncContext param to ConnectorConfig.getDocument interface
- Use syncContext in Evernote getDocument to cache tag/notebook maps
- Replace index-based cache check with Map keyed by KB ID in search route

* fix(knowledge): address second round of PR review feedback

- Fix Zod .default('text') overriding tag definition's actual fieldType
- Fix encodeURIComponent breaking multi-level folder paths in Obsidian
- Use 413 instead of 400 for file-too-large in document upload
- Add knowledge-bases to API reference docs navigation

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

* fix(knowledge): prevent cross-workspace KB access in search

Filter accessible KBs by matching workspaceId from the request,
preventing users from querying KBs in other workspaces they have
access to but didn't specify.

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

* fix(knowledge): audit resourceId, SSRF protection, recursion depth limit

- Fix recordAudit using knowledgeBaseId instead of newDocument.id
- Add SSRF validation to Obsidian connector (reject private/loopback URLs)
- Add max recursion depth (20) to listVaultFiles

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

* fix(obsidian): remove SSRF check that blocks localhost usage

The Obsidian connector is designed to connect to the Local REST API
plugin running on localhost (127.0.0.1:27124). The SSRF check was
incorrectly blocking this primary use case.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:00:38 -08:00
Emir Karabeg
13d49da8bd improvement(resources): layout and items 2026-03-07 18:01:29 -08:00
Waleed
05b8481a89 fix(knowledge): compute KB tokenCount from documents instead of stale column (#3463)
The knowledge_base.token_count column was initialized to 0 and never
updated. Replace with COALESCE(SUM(document.token_count), 0) in all
read queries, which already JOIN on documents with GROUP BY.
2026-03-07 16:55:06 -08:00
Emir Karabeg
6690c55721 improvement(resource): layout 2026-03-07 16:25:53 -08:00
Siddharth Ganesan
88a8c5f4a1 Update mothership to match copilot in logs 2026-03-07 16:18:34 -08:00
Siddharth Ganesan
91ca6a531e Fix tables row count 2026-03-07 16:04:36 -08:00
Emir Karabeg
2f45f935e4 ran lint 2026-03-07 15:47:53 -08:00
Waleed
2cb12de546 refactor(queries): comprehensive TanStack Query best practices audit (#3460)
* refactor: comprehensive TanStack Query best practices audit and migration

- Add AbortSignal forwarding to all 41 queryFn implementations for proper request cancellation
- Migrate manual fetch patterns to useMutation hooks (useResetPassword, useRedeemReferralCode, usePurchaseCredits, useImportWorkflow, useOpenBillingPortal, useAllowedMcpDomains)
- Migrate standalone hooks to TanStack Query (use-next-available-slot, use-mcp-server-test, use-webhook-management, use-referral-attribution)
- Fix query key factories: add missing `all` keys, replace inline keys with factory methods
- Fix optimistic mutations: use onSettled instead of onSuccess for cache reconciliation
- Replace overly broad cache invalidations with targeted key invalidation
- Remove keepPreviousData from static-key queries where it provides no benefit
- Add staleTime to queries missing explicit cache duration
- Fix `any` type in UpdateSettingParams with proper GeneralSettings typing
- Remove dead code: loadingWebhooks/checkedWebhooks from subblock store, unused helper functions
- Update settings components (general, debug, referral-code, credit-balance, subscription, mcp) to use mutation state instead of manual useState for loading/error/success

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

* fix: remove unstable mutation object from useCallback deps

openBillingPortal mutation object is not referentially stable,
but .mutate() is stable in TanStack Query v5. Remove from deps
to prevent unnecessary handleBadgeClick recreations.

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

* fix: add missing byWorkflows invalidation to useUpdateTemplate

The onSettled handler was missing the byWorkflows() invalidation
that was dropped during the onSuccess→onSettled migration. Without
this, the deploy modal (useTemplateByWorkflow) would show stale data
after a template update.

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

* docs: add TanStack Query best practices to CLAUDE.md and cursor rules

Add comprehensive React Query best practices covering:
- Hierarchical query key factories with intermediate plural keys
- AbortSignal forwarding in all queryFn implementations
- Targeted cache invalidation over broad .all invalidation
- onSettled for optimistic mutation cache reconciliation
- keepPreviousData only on variable-key queries
- No manual fetch in components rule
- Stable mutation references in useCallback deps

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

* fix: address PR review feedback

- Fix syncedRef regression in use-webhook-management: only set
  syncedRef.current=true when webhook is found, so re-sync works
  after webhook creation (e.g., post-deploy)
- Remove redundant detail(id) invalidation from useUpdateTemplate
  onSettled since onSuccess already populates cache via setQueryData

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

* fix: address second round of PR review feedback

- Reset syncedRef when blockId changes in use-webhook-management so
  component reuse with a different block syncs the new webhook
- Add response.ok check in postAttribution so non-2xx responses
  throw and trigger TanStack Query retry logic

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

* fix: use lists() prefix invalidation in useCreateWorkspaceCredential

Use workspaceCredentialKeys.lists() instead of .list(workspaceId) so
filtered list queries are also invalidated on credential creation,
matching the pattern used by update and delete mutations.

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

* fix: address third round of PR review feedback

- Add nullish coalescing fallback for bonusAmount in referral-code
  to prevent rendering "undefined" when server omits the field
- Reset syncedRef when queryEnabled becomes false so webhook data
  re-syncs when the query is re-enabled without component remount

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

* fix: address fourth round of PR review feedback

- Add AbortSignal to testMcpServerConnection for consistency
- Wrap handleTestConnection in try/catch for mutateAsync error handling
- Replace broad subscriptionKeys.all with targeted users()/usage() invalidation
- Add intermediate users() key to subscription key factory for prefix matching
- Add comment documenting syncedRef null-webhook behavior
- Fix api-keys.ts silent error swallowing on non-ok responses
- Move deployments.ts cache invalidation from onSuccess to onSettled

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

* fix: achieve full TanStack Query best practices compliance

- Add intermediate plural keys to api-keys, deployments, and schedules
  key factories for prefix-based invalidation support
- Change copilot-keys from refetchQueries to invalidateQueries
- Add signal parameter to organization.ts fetch functions (better-auth
  client does not support AbortSignal, documented accordingly)
- Move useCreateMcpServer invalidation from onSuccess to onSettled

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:15:10 -08:00
Emir Karabeg
de32644940 improvement(resources): all outer page structure complete 2026-03-07 14:42:11 -08:00
Emir Karabeg
8ff93fe842 improvement(resource): tables, files 2026-03-07 13:42:22 -08:00
Waleed
0d9e04181f improvement(perf): apply react and js performance optimizations across codebase (#3459)
* improvement(perf): apply react and js performance optimizations across codebase

- Parallelize independent DB queries with Promise.all in API routes
- Defer PostHog and OneDollarStats via dynamic import() to reduce bundle size
- Use functional setState in countdown timers to prevent stale closures
- Replace O(n*m) .filter().find() with Set-based O(n) lookups in undo-redo
- Use .toSorted() instead of .sort() for immutable state operations
- Use lazy initializers for useState(new Set()) across 20 components
- Remove useMemo wrapping trivially cheap expressions (typeof, ternary, template strings)
- Add passive: true to scroll event listener

* fix(perf): address PR review feedback

- Extract IIFE Set patterns to named consts for readability in use-undo-redo
- Hoist Set construction above loops in BATCH_UPDATE_PARENT cases
- Add .catch() error handler to PostHog dynamic import
- Convert session-provider posthog import to dynamic import() to complete bundle split

* fix(analytics): add .catch() to onedollarstats dynamic import
2026-03-07 13:08:26 -08:00
Waleed
1324987def improvement(turbo): align turborepo config with best practices (#3458)
* improvement(turbo): align turborepo config with best practices

* fix(turbo): address PR review feedback

* fix(turbo): add lint:check task for read-only lint+format CI checks

lint:check previously delegated to format:check which only checked
formatting. Now it runs biome check (no --write) which enforces both
lint rules and formatting without mutating files.

* upgrade turbo
2026-03-07 12:38:46 -08:00
Waleed
9c4abf7b9b fix(connectors): add rate limiting, concurrency controls, and bug fixes (#3457)
* fix(connectors): add rate limiting, concurrency controls, and bug fixes across knowledge connectors

- Add Retry-After header support to fetchWithRetry for all 18 connectors
- Batch concurrent API calls (concurrency 5) in Dropbox, Google Docs, Google Drive, OneDrive, SharePoint
- Batch concurrent API calls (concurrency 3) in Notion to match 3 req/s limit
- Cache GitHub tree in syncContext to avoid re-fetching on every pagination page
- Batch GitHub blob fetches with concurrency 5
- Fix GitHub base64 decoding: atob() → Buffer.from() for UTF-8 safety
- Fix HubSpot OAuth scope: 'tickets' → 'crm.objects.tickets.read' (v3 API)
- Fix HubSpot syncContext key: totalFetched → totalDocsFetched for consistency
- Add jitter to nextSyncAt (10% of interval, capped at 5min) to prevent thundering herd
- Fix Date consistency in connector DELETE route

* fix(connectors): address PR review feedback on retry and SharePoint batching

- Remove 120s cap on Retry-After — pass all values through to retry loop
- Add maxDelayMs guard: if Retry-After exceeds maxDelayMs, throw immediately
  instead of hammering with shorter intervals (addresses validate timeout concern)
- Add early exit in SharePoint batch loop when maxFiles limit is reached
  to avoid unnecessary API calls

* fix(connectors): cap Retry-After at maxDelayMs instead of aborting

Match Google Cloud SDK behavior: when Retry-After exceeds maxDelayMs,
cap the wait to maxDelayMs and log a warning, rather than throwing
immediately. This ensures retries are bounded in duration while still
respecting server guidance within the configured limit.

* fix(connectors): add early-exit guard to Dropbox, Google Docs, OneDrive batch loops

Match the SharePoint fix — skip remaining batches once maxFiles limit
is reached to avoid unnecessary API calls.
2026-03-07 12:12:15 -08:00
Siddharth Ganesan
00c9b72bdd Fix lint 2026-03-07 12:06:12 -08:00
Siddharth Ganesan
386df7a062 Fix 2026-03-07 11:47:53 -08:00
Siddharth Ganesan
0967755ad4 Clean vfs 2026-03-07 11:29:20 -08:00
Siddharth Ganesan
b50ccdf314 Fixes 2026-03-07 11:14:13 -08:00
Siddharth Ganesan
7247a5f4d8 Fixes 2026-03-07 10:43:41 -08:00
Waleed Latif
875498c9aa fix: resolve post-merge test and lint failures
- airtable: sync tableSelector condition with tableId (add getSchema)
- backfillCanonicalModes test: add documentId mode to prevent false backfill
- schedule PUT test: use invalid action string now that disable is valid
- schedule execute tests: add ne mock, sourceType field, use
  mockReturnValueOnce for two db.update calls
- knowledge tools: fix biome formatting (single-line arrow functions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:49:57 -08:00
Waleed Latif
3c196d180f lint 2026-03-07 01:45:56 -08:00
Waleed Latif
2940de946c fix: correct knowledge block canonical pair pattern and subblock migration
- Rename manualDocumentId to documentId (advanced subblock ID should match
  canonicalParamId, consistent with airtable/gmail patterns)
- Fix documentSelector.dependsOn to reference knowledgeBaseSelector (basic
  depends on basic, not advanced)
- Remove unnecessary documentId migration (ID unchanged from main)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:38:05 -08:00
Waleed Latif
212f912827 Merge staging into feat/mothership-copilot
Resolved conflicts:
- oauth-required-modal.tsx: removed local SCOPE_DESCRIPTIONS (moved to lib/oauth/utils)
- credential-selector.tsx (2 files): kept useSettingsNavigation import, removed duplicate getMissingRequiredScopes
- airtable.ts: combined HEAD's dependsOn/getSchema with staging's mode:'advanced'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:25:48 -08:00
Emir Karabeg
9a505919b0 refactor, improvement 2026-03-06 23:44:34 -08:00
Waleed
e6ca3b3311 feat(knowledge): add connector tools and expand document metadata (#3452)
* feat(knowledge): add connector tools and expand document metadata

* fix(knowledge): address PR review feedback on new tools

* fix(knowledge): remove unused params from get_document transform
2026-03-06 17:58:33 -08:00
Waleed
b93c87c521 fix(fireflies): correct types from live API validation (#3450)
* fix(fireflies): correct types from live API validation

- speakers.id is number, not string (API returns 0, 1, 2...)
- summary.action_items is a single string, not string[]
- Update formatTranscriptContent to handle action_items as string

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

* fix(fireflies): correct tool types from live API validation

- FirefliesSpeaker.id: string -> number
- FirefliesSentence.speaker_id: string -> number
- FirefliesSpeakerAnalytics.speaker_id: string -> number
- FirefliesSummary.action_items: string[] -> string
- FirefliesSummary.outline: string[] -> string
- FirefliesSummary.shorthand_bullet: string[] -> string
- FirefliesSummary.bullet_gist: string[] -> string
- FirefliesSummary.topics_discussed: string[] -> string

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:46:42 -08:00
Siddharth Ganesan
06a4a8162a Add context 2026-03-06 17:11:18 -08:00
Waleed
96c2ae2c39 feat(connectors): add Fireflies connector and API key auth support (#3448)
* feat(connectors): add Fireflies connector and API key auth support

Extend the connector system to support both OAuth and API key authentication
via a discriminated union (`ConnectorAuthConfig`). Add Fireflies as the first
API key connector, syncing meeting transcripts via the Fireflies GraphQL API.

Schema changes:
- Make `credentialId` nullable (null for API key connectors)
- Add `encryptedApiKey` column (AES-256-GCM encrypted, null for OAuth)

This eliminates the `'_apikey_'` sentinel and inline `sourceConfig._encryptedApiKey`
patterns, giving each auth mode its own clean column.

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

* fix(fireflies): allow 0 for maxTranscripts (means unlimited)

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:48:39 -08:00
Theodore Li
1e53d5748a Fix oauth link callback from mothership task 2026-03-06 13:46:08 -08:00
Waleed Latif
6d803bcde2 fix(knowledge): pass workspaceId to useOAuthCredentials in connector card
The ConnectorCard was calling useOAuthCredentials(providerId) without
a workspaceId, causing the credentials API to return an empty array.
This meant the credential lookup always failed, getMissingRequiredScopes
received undefined, and the "Update access" banner always appeared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:08:14 -08:00
Waleed Latif
bca131d597 fix(connectors): restore Linear connector requiredScopes
Linear OAuth does return scopes in the token response. The previous
fix of emptying requiredScopes was based on an incorrect assumption.
Restoring requiredScopes: ['read'] as it should work correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:55:06 -08:00
Waleed Latif
0202c60d26 Revert "fix(connectors): remove legacy requiredScopes from Jira and Confluence connectors"
This reverts commit a0be3ff414.
2026-03-06 12:52:29 -08:00
Waleed
82ba3d7dd1 feat(tasks): add rename to task context menu (#3442) 2026-03-06 12:49:32 -08:00
Waleed Latif
a0be3ff414 fix(connectors): remove legacy requiredScopes from Jira and Confluence connectors
Jira and Confluence OAuth tokens don't return legacy scope names like
read:jira-work or read:confluence-content.all, causing the 'Update access'
banner to always appear. Set requiredScopes to empty array like Linear.
2026-03-06 12:44:46 -08:00
Waleed Latif
6cda9e60e8 fix(connectors): remove unverifiable requiredScopes for Linear connector 2026-03-06 12:40:15 -08:00
Siddharth Ganesan
a6abb9da67 Job logs 2026-03-06 12:30:33 -08:00
Siddharth Ganesan
777e4f57de Job exeuction logs 2026-03-06 11:34:48 -08:00
Waleed
695628de75 improvement(knowledge): make connector-synced document chunks readonly (#3440)
* improvement(knowledge): make connector-synced document chunks readonly

* fix(knowledge): enforce connector chunk readonly on server side

* fix(knowledge): disable toggle and delete actions for connector-synced chunks
2026-03-06 11:29:28 -08:00
Siddharth Ganesan
d71e4e51ea Fix mothership block logs 2026-03-06 10:57:16 -08:00
Siddharth Ganesan
576d9d3025 Mothership block logs 2026-03-06 10:46:30 -08:00
Waleed
43509374a2 fix(sidebar): use client-generated UUIDs for stable optimistic updates (#3439)
* fix(sidebar): use client-generated UUIDs for stable optimistic updates

* fix(folders): use zod schema validation for folder create API

Replace inline UUID regex with zod schema validation for consistency
with other API routes. Update test expectations accordingly.

* fix(sidebar): add client UUID to single workflow duplicate hook

The useDuplicateWorkflow hook was missing newId: crypto.randomUUID(),
causing the same temp-ID-swap issue for single workflow duplication
from the context menu.

* fix(folders): avoid unnecessary Set re-creation in replaceOptimisticEntry

Only create new expandedFolders/selectedFolders Sets when tempId
differs from data.id. In the common happy path (client-generated UUIDs),
this avoids unnecessary Zustand state reference changes and re-renders.
2026-03-06 06:35:19 -08:00
Emir Karabeg
0e7c719e82 improvement(sidebar): loading 2026-03-06 02:35:24 -08:00
Siddharth Ganesan
226a3f64fb Fix lint 2026-03-05 22:10:27 -08:00
Siddharth Ganesan
6c6b3579c9 Triggers in the vfs 2026-03-05 20:57:58 -08:00
Siddharth Ganesan
a5b148e19e Native kb connectors 2026-03-05 20:17:23 -08:00
Emir Karabeg
9665f49492 fix(workflow): editor visible 2026-03-05 20:07:44 -08:00
Waleed Latif
ff4b2f8c6a lint 2026-03-05 19:38:51 -08:00
Waleed Latif
4735245c8f feat(tables): inline cell editing with optimistic updates 2026-03-05 19:37:06 -08:00
Waleed
aac9e74283 feat(knowledge): add 10 new knowledge base connectors (#3430)
* feat(knowledge): add 10 new knowledge base connectors

Add connectors for Dropbox, OneDrive, SharePoint, Slack, Google Docs,
Asana, HubSpot, Salesforce, WordPress, and Webflow. Each connector
implements listDocuments, getDocument, validateConfig with proper
pagination, content hashing, and tag definitions.

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

* fix(connectors): address audit findings across 5 connectors

OneDrive: fix encodeURIComponent breaking folder paths with slashes,
add recursive folder traversal via folder queue in cursor state.
Slack: add missing requiredScopes.
Asana: pass retryOptions as 3rd arg to fetchWithRetry instead of
spreading into RequestInit; add missing requiredScopes.
HubSpot: add missing requiredScopes; fix sort property to use
hs_lastmodifieddate for non-contact object types.
Google Docs: remove orphaned title tag that was never populated.

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

* fix(connectors): add missing requiredScopes to OneDrive and HubSpot

OneDrive: add requiredScopes: ['Files.Read']
HubSpot: add missing crm.objects.tickets.read scope

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

* chore(connectors): lint fixes

* fix(connectors): slice documents to respect max limit on last page

* fix(connectors): use per-segment encodeURIComponent for SharePoint folder paths

encodeURI does not encode #, ?, &, + or = which are valid in folder
names but break the Microsoft Graph URL. Apply the same per-segment
encoding fix already used in the OneDrive connector.

* fix(connectors): address PR review findings

- Slack: remove private_channel from conversations.list types param
  since requiredScopes only cover public channels (channels:read,
  channels:history). Adding groups:read/groups:history would force
  all users to grant private channel access unnecessarily.
- OneDrive/SharePoint: add .htm to supported extensions and handle
  it in content processing (htmlToPlainText), matching Dropbox.
- Salesforce: guard getDocument for KnowledgeArticleVersion to skip
  records that are no longer PublishStatus='Online', preventing
  un-published articles from being re-synced.

* fix(connectors): pre-download size check and remove dead parameter

- OneDrive/SharePoint: add file size check against MAX_FILE_SIZE before
  downloading, matching Dropbox's behavior. Prevents OOM on large files.
- Slack: remove unused syncContext parameter from fetchChannelMessages.

* fix(connectors): slack getDocument user cache & wordpress scope reduction

- Slack: pass a local syncContext to formatMessages in getDocument so
  resolveUserName caches user lookups across messages. Without this,
  every message triggered a fresh users.info API call.
- WordPress: replace 'global' scope with 'posts' and 'sites' following
  principle of least privilege. The connector only reads posts and
  validates site existence.

* fix(connectors): revert wordpress scope and slack local cache changes

- WordPress: revert requiredScopes to ['global'] — the scope check
  does literal string matching, so ['posts', 'sites'] would always
  fail since auth.ts requests 'global' from WordPress.com OAuth.
  Reducing scope requires changing both auth.ts and the connector.
- Slack: remove local syncContext from getDocument — the perf impact
  of uncached users.info calls is negligible for typical channels
  (bounded by unique users, not message count).

* fix(connectors): align requiredScopes with auth.ts registrations

The scope check in getMissingRequiredScopes does literal string matching
against the OAuth token's granted scopes. requiredScopes must match what
auth.ts actually requests (since that's what the provider returns).

- HubSpot: use 'tickets' (legacy scope in auth.ts) instead of
  'crm.objects.tickets.read' (v3 granular scope not requested)
- Google Docs: use 'drive' (what auth.ts requests) instead of
  'documents.readonly' and 'drive.readonly' (never requested,
  so never in the granted set)

* fix(connectors): align Google Drive requiredScopes with auth.ts

Google Drive connector required 'drive.readonly' but auth.ts requests
'drive' (the superset). Since scope validation does literal matching,
this caused a spurious 'Additional permissions required' warning.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:31:17 -08:00
Siddharth Ganesan
d6b97fee08 Fix lint 2026-03-05 17:37:41 -08:00
Siddharth Ganesan
280ac30d55 Jobs 2026-03-05 17:36:24 -08:00
Siddharth Ganesan
5c24d2422e Jobs 2026-03-05 17:35:38 -08:00
Siddharth Ganesan
9d001eaf70 Jobs 2026-03-05 17:32:36 -08:00
Vikhyath Mondreti
3ce947566d v0.5.106: condition block and legacy kbs fixes, GPT 5.4 2026-03-05 17:30:05 -08:00
Siddharth Ganesan
17e1bb5331 Nuke migrations 2026-03-05 17:22:40 -08:00
Siddharth Ganesan
443e15eb01 Jobs 2026-03-05 16:43:48 -08:00
Waleed
dbef14ba26 feat(knowledge): connectors, user exclusions, expanded tools & airtable integration (#3230)
* feat(knowledge): connectors, user exclusions, expanded tools & airtable integration

* improvements

* removed redundant util

* ack PR comments

* remove module level cache, use syncContext between paginated calls to avoid redundant schema fetches

* regen migrations, ack PR comments

* ack PR comment

* added tests

* ack comments

* ack comments

* feat(db): add knowledge connector migration after merge

Generated migration 0162 for knowledge_connector and
knowledge_connector_sync_log tables after resolving merge
conflicts with feat/mothership-copilot.

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

* fix(connectors): audit fixes for sync engine, connectors, and knowledge tools

- Extract shared computeContentHash to connectors/utils.ts (dedup across 7 connectors)
- Include error'd connectors in cron auto-retry query
- Add syncContext caching for Confluence (cloudId, spaceId)
- Batch Confluence label fetches with concurrency limit of 10
- Enforce maxPages in Confluence v2 path
- Clean up stale storage files on document update
- Retry stuck documents (pending/failed) after sync completes
- Soft-delete documents and reclaim tag slots on connector deletion
- Add incremental sync support to ConnectorConfig interface
- Fix offset:0 falsy check in list_documents tool

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

* perf(connectors): deep audit — extract shared utils, fix pagination, optimize API calls

- Extract shared htmlToPlainText to connectors/utils.ts (dedup Confluence + Google Drive)
- Add syncContext caching for Jira cloudId, Notion/Linear/Google Drive cumulative limits
- Fix cumulative maxPages/maxIssues/maxFiles enforcement across pagination pages
- Bump Notion page_size from 20 to 100 (5x fewer API round-trips)
- Batch Notion child page fetching with concurrency=5 (was serial N+1)
- Bump Confluence v2 limit from 50 to 250 (v2 API supports it)
- Pass syncContext through Confluence CQL path for cumulative tracking
- Upgrade GitHub tree truncation warning to error level
- Fix sync-engine test mock to include inArray export

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

* refactor(connectors): extract tag helpers, fix Notion maxPages, rewrite broken tests

- Add parseTagDate and joinTagArray helpers to connectors/utils.ts
- Update all 7 connectors to use shared tag mapping helpers (removes 12+ duplication instances)
- Fix Notion listFromParentPage cumulative maxPages check (was using local count)
- Rewrite 3 broken connector route test files to use vi.hoisted() + static vi.mock()
  pattern instead of deprecated vi.doMock/vi.resetModules (all 86 tests now pass)

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

* fix(connectors): add loading skeletons, delete pending state, and pause feedback

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

* fix(knowledge): escape LIKE wildcards, guard restore from un-deleting, fix offset=0

- Escape %, _, \ in tag filter LIKE patterns to prevent incorrect matches
- Add isNull(deletedAt) guard to restore operation to prevent un-deleting soft-deleted docs
- Change offset check from falsy to != null so offset=0 is not dropped

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:40:00 -08:00
Siddharth Ganesan
7140867ff9 Jobs 2026-03-05 15:14:45 -08:00
Siddharth Ganesan
73cd10ca21 Jobs 2 2026-03-05 14:40:49 -08:00
Waleed
a368827f1e feat(api): add tables and files v1 REST API with OpenAPI docs (#3422)
* feat(api): add tables and files v1 REST API with OpenAPI docs

* fix(api): address review feedback for tables/files REST API

* fix(api): reject empty filters, consolidate PUT/DELETE into service helpers

* fix(api): upsert unique constraints, POST response fields, uploadedAt timestamp

* fix(api): stop leaking internal fields in list tables, fix deleteTable requestId

* fix(api): atomic table-count limit in createTable, stop leaking internal fields

* fix(api): error classification in PATCH, z.coerce→preprocess, requestId in logs

* fix(api): audit logging, PATCH service consolidation, Content-Disposition encoding

- Add TABLE_CREATED/TABLE_DELETED audit events to v1 table routes
- Consolidate PATCH handlers to use updateRow service function
- Fix Content-Disposition header with RFC 5987 dual-parameter form
- Normalize schema in POST /tables response with normalizeColumn

* lint

* fix(api): upsert unique constraint 400, guard request.json() parse errors

- Add 'Unique constraint violation' to upsert error classification
- Wrap PUT/DELETE request.json() in try/catch to return 400 on malformed body
- Apply fixes to both v1 and internal routes

* fix(api): guard PATCH request.json(), accurate deleteRowsByIds count

- Wrap PATCH request.json() in try/catch for both v1 and internal routes
- Rewrite deleteRowsByIds to use .returning() for accurate deletedCount
  under concurrent requests (eliminates SELECT-then-DELETE race)

* fix(api): guard all remaining request.json() calls in table routes

- Wrap POST handler request.json() in try/catch across all table routes
- Also fix internal DELETE single-row handler
- Every request.json() in table routes now returns 400 on malformed body

* fix(api): safe type check on formData workspaceId in file upload

- Replace unsafe `as string | null` cast with typeof check
- Prevents File object from bypassing workspaceId validation

* fix(api): safe File cast in upload, validate column name before sql.raw()

- Use instanceof File check instead of unsafe `as File | null` cast
- Add regex validation on column name before sql.raw() interpolation

* fix(api): comprehensive hardening pass across all table/file routes

- Guard request.formData() with try/catch in file upload
- Guard all .toISOString() calls with instanceof Date checks
- Replace verifyTableWorkspace double-fetch with direct comparison
- Fix relative imports to absolute (@/app/api/table/utils)
- Fix internal list tables leaking fields via ...t spread
- Normalize schema in internal POST create table response
- Remove redundant pre-check in internal create (service handles atomically)
- Make 'maximum table limit' return 403 consistently (was 400 in internal)
- Add 'Row not found' → 404 classification in PATCH handlers
- Add NAME_PATTERN validation before sql.raw() in validation.ts

* chore: lint fixes
2026-03-05 13:16:13 -08:00
Siddharth Ganesan
eac8aca0c0 Schedules page for workflows 2026-03-05 10:31:01 -08:00
Waleed
70c36cb7aa v0.5.105: slack remove reaction, nested subflow locks fix, servicenow pagination, memory improvements 2026-03-04 22:38:26 -08:00
Siddharth Ganesan
337154054e Oauth link 2026-03-04 17:35:32 -08:00
Siddharth Ganesan
c6ac0b4445 Agent subdir 2026-03-04 16:50:24 -08:00
Waleed
b07925fcc0 feat(settings): migrate settings from modal to route-based pages (#3413) 2026-03-04 15:20:52 -08:00
Siddharth Ganesan
08fb8c1651 Tool perms 2026-03-04 13:44:46 -08:00
Siddharth Ganesan
37337aece5 Scope perms 2026-03-04 12:44:04 -08:00
Siddharth Ganesan
da349176ab Fix merge conflicts 2026-03-04 11:17:01 -08:00
Siddharth Ganesan
6f3559ce8f Fix merge conflicts 2026-03-04 11:15:43 -08:00
Siddharth Ganesan
9a7b5ffe64 Fix merge conflicts 2026-03-04 11:13:42 -08:00
Siddharth Ganesan
4ede071ecb Fix merge conflicts 2026-03-04 11:12:51 -08:00
Siddharth Ganesan
161fb37244 Remove migrations 2026-03-04 10:48:12 -08:00
Emir Karabeg
d1575927a2 improvement(theme): system default 2026-03-04 01:29:47 -08:00
Waleed
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -08:00
Emir Karabeg
a3b19fb32a improvement(user-input): ui, files 2026-03-03 22:56:07 -08:00
Emir Karabeg
21404d17e8 fix: message stream pickup and task ordering 2026-03-03 17:29:36 -08:00
Siddharth Ganesan
df7e731c9c Add payload 2026-03-03 16:15:01 -08:00
Emir Karabeg
4f4191fe1b fix: task ordering 2026-03-03 15:45:15 -08:00
Emir Karabeg
b57636e5b1 finalized task navigation 2026-03-03 15:24:31 -08:00
Emir Karabeg
38c9ecd259 resolved merge conflicts 2026-03-03 14:49:56 -08:00
Emir Karabeg
fadda6aaef improvement: task routing optimizations 2026-03-03 14:48:43 -08:00
Emir Karabeg
82f541e9de improvement: ui 2026-03-03 14:46:09 -08:00
Siddharth Ganesan
1339915957 Task vfs 2026-03-03 13:12:13 -08:00
Siddharth Ganesan
7fafc00a07 Task management 2026-03-03 12:00:03 -08:00
Emir Karabeg
fe5ab8aee8 improved streaming 2026-03-03 11:40:42 -08:00
Siddharth Ganesan
b3a639a693 Logs 2026-03-03 11:38:06 -08:00
Siddharth Ganesan
0249ca1480 Fix files 2026-03-03 10:49:59 -08:00
Siddharth Ganesan
553c376289 Fix routes 2026-03-03 10:23:11 -08:00
Emir Karabeg
4622966643 improvement(home): interactions 2026-03-02 17:25:32 -08:00
Siddharth Ganesan
e9550c624d Wand 2026-03-02 15:12:59 -08:00
Siddharth Ganesan
1d48289c53 Mothership block pudate 2026-03-02 15:05:56 -08:00
Siddharth Ganesan
fce10241a5 Mothership block 2026-03-02 14:55:04 -08:00
Emir Karabeg
ae080f125c Merge branch 'feat/landing' into feat/mothership-copilot 2026-03-02 13:44:12 -08:00
Emir Karabeg
0fb840c8fd Cleaned up home 2026-03-02 13:39:34 -08:00
Emir Karabeg
2c20519bbd improvement: ui/ux 2026-03-02 12:36:32 -08:00
Siddharth Ganesan
f3474b0c90 Tool call loop 2026-03-02 11:15:17 -08:00
Waleed
e07e3c34cc v0.5.103: memory util instrumentation, API docs, amplitude, google pagespeed insights, pagerduty 2026-03-01 23:27:02 -08:00
Siddharth Ganesan
b2cc5b6738 Billing 2026-02-28 17:51:26 -08:00
Waleed
0d2e6ff31d v0.5.102: new integrations, new tools, ci speedups, memory leak instrumentation 2026-02-28 12:48:10 -08:00
Siddharth Ganesan
d49a2c1c25 Fixes 2026-02-27 15:56:04 -08:00
Siddharth Ganesan
8fa4745893 MCP commented out 2026-02-27 11:18:38 -08:00
Siddharth Ganesan
c168e36a05 Fix 2026-02-26 17:48:53 -08:00
Siddharth Ganesan
9cc46ffa43 Edit subagents 2026-02-26 15:53:58 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Siddharth Ganesan
cc5e592c46 Kb checkpoint 2026-02-26 14:59:56 -08:00
Siddharth Ganesan
7276136398 Piping 2026-02-26 12:32:09 -08:00
Siddharth Ganesan
3ad7af4b97 File creation 2026-02-25 19:23:24 -08:00
Siddharth Ganesan
3cb1768a44 Move files to separate resource 2026-02-25 18:33:07 -08:00
Siddharth Ganesan
11e6387a7d Fix run workflow 2026-02-25 18:12:13 -08:00
Siddharth Ganesan
57a91027de Fix condition edges 2026-02-25 17:48:33 -08:00
Emir Karabeg
49c29d5f7d feat: pricing, collaboration improvement, features skeleton 2026-02-25 16:28:56 -08:00
Emir Karabeg
843af915bc feat: integrations skeleton, realtime complete 2026-02-25 16:28:56 -08:00
Emir Karabeg
bb3e899f74 feat(landing): template, generic workflow 2026-02-25 16:28:56 -08:00
Emir Karabeg
e47dcdcc43 feat(landing): navbar, metadata, hero, templates header 2026-02-25 16:28:55 -08:00
Emir Karabeg
3e6cf24762 feat(landing): structure 2026-02-25 16:28:55 -08:00
Siddharth Ganesan
90a12546b2 Fix lint 2026-02-25 12:56:58 -08:00
Siddharth Ganesan
b6f8439267 Remove dead code 2026-02-25 12:55:50 -08:00
Siddharth Ganesan
4f74a8b845 Checkpopint 2026-02-25 12:45:55 -08:00
Siddharth Ganesan
f12d8f631f Split 2026-02-25 12:37:23 -08:00
Siddharth Ganesan
41f0957ccc Separation of route 2026-02-25 12:19:26 -08:00
Siddharth Ganesan
7b813be1dd Fix truncation 2026-02-25 11:09:04 -08:00
Siddharth Ganesan
704fa16bb4 run workflow checkpoint 2026-02-25 11:08:44 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Siddharth Ganesan
eccad2a8ce Remove dup code from tool calls 2026-02-24 16:59:40 -08:00
Siddharth Ganesan
87f5c464d9 Consolidation 2026-02-24 14:55:35 -08:00
Siddharth Ganesan
724aaa1432 table tools 2026-02-24 14:32:55 -08:00
Siddharth Ganesan
3de3ef4786 Readd migration 2026-02-24 14:03:30 -08:00
Siddharth Ganesan
743f048442 Merge with origin staging 2026-02-24 14:02:59 -08:00
Siddharth Ganesan
bbcf346df0 Nuke migration 2026-02-24 13:57:31 -08:00
Siddharth Ganesan
b9c3c2f78f Checkpoint interface consolidation 2026-02-24 13:55:50 -08:00
Siddharth Ganesan
d333307a17 Checkpoint 2026-02-24 13:47:29 -08:00
Siddharth Ganesan
134c4c4f2a Checkpoint 2026-02-24 12:22:19 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

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

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

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

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

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

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Siddharth Ganesan
03908edcbb Checkpoint 2026-02-19 14:47:57 -08:00
Siddharth Ganesan
3112485c31 Checkpoint 2026-02-19 11:08:32 -08:00
Siddharth Ganesan
459c2930ae Checkpoint 2026-02-19 10:14:24 -08:00
Siddharth Ganesan
3338b25c30 Checkpoint 2026-02-18 18:55:10 -08:00
Siddharth Ganesan
4c3002f97d Checkpoint 2026-02-18 18:38:37 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Siddharth Ganesan
632e0e0762 Checkpoitn 2026-02-18 15:29:58 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Siddharth Ganesan
7599774974 Checkpoint 2026-02-17 18:54:15 -08:00
Siddharth Ganesan
471e58a2d0 Checkpoint 2026-02-17 17:04:34 -08:00
Siddharth Ganesan
231ddc59a0 V0 2026-02-17 16:07:55 -08:00
Siddharth Ganesan
b197f68828 v0 2026-02-17 15:28:23 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
892 changed files with 158033 additions and 18612 deletions

View File

@@ -0,0 +1,299 @@
---
description: Add a knowledge base connector for syncing documents from an external source
argument-hint: <service-name> [api-docs-url]
---
# Add Connector Skill
You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base.
## Your Task
When the user asks you to create a connector:
1. Use Context7 or WebFetch to read the service's API documentation
2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth)
3. Create the connector directory and config
4. Register it in the connector registry
## Directory Structure
Create files in `apps/sim/connectors/{service}/`:
```
connectors/{service}/
├── index.ts # Barrel export
└── {service}.ts # ConnectorConfig definition
```
## Authentication
Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`):
```typescript
type ConnectorAuthConfig =
| { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] }
| { mode: 'apiKey'; label?: string; placeholder?: string }
```
### OAuth mode
For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The `provider` must match an `OAuthService`. The modal shows a credential picker and handles token refresh automatically.
### API key mode
For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`.
## ConnectorConfig Structure
### OAuth connector example
```typescript
import { createLogger } from '@sim/logger'
import { {Service}Icon } from '@/components/icons'
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('{Service}Connector')
export const {service}Connector: ConnectorConfig = {
id: '{service}',
name: '{Service}',
description: 'Sync documents from {Service} into your knowledge base',
version: '1.0.0',
icon: {Service}Icon,
auth: {
mode: 'oauth',
provider: '{service}', // Must match OAuthService in lib/oauth/types.ts
requiredScopes: ['read:...'],
},
configFields: [
// Rendered dynamically by the add-connector modal UI
// Supports 'short-input' and 'dropdown' types
],
listDocuments: async (accessToken, sourceConfig, cursor) => {
// Paginate via cursor, extract text, compute SHA-256 hash
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
},
getDocument: async (accessToken, sourceConfig, externalId) => {
// Return ExternalDocument or null
},
validateConfig: async (accessToken, sourceConfig) => {
// Return { valid: true } or { valid: false, error: 'message' }
},
// Optional: map source metadata to semantic tag keys (translated to slots by sync engine)
mapTags: (metadata) => {
// Return Record<string, unknown> with keys matching tagDefinitions[].id
},
}
```
### API key connector example
```typescript
export const {service}Connector: ConnectorConfig = {
id: '{service}',
name: '{Service}',
description: 'Sync documents from {Service} into your knowledge base',
version: '1.0.0',
icon: {Service}Icon,
auth: {
mode: 'apiKey',
label: 'API Key', // Shown above the input field
placeholder: 'Enter your {Service} API key', // Input placeholder
},
configFields: [ /* ... */ ],
listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ },
getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ },
validateConfig: async (accessToken, sourceConfig) => { /* ... */ },
}
```
## ConfigField Types
The add-connector modal renders these automatically — no custom UI needed.
```typescript
// Text input
{
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'yoursite.example.com',
required: true,
}
// Dropdown (static options)
{
id: 'contentType',
title: 'Content Type',
type: 'dropdown',
required: false,
options: [
{ label: 'Pages only', id: 'page' },
{ label: 'Blog posts only', id: 'blogpost' },
{ label: 'All content', id: 'all' },
],
}
```
## ExternalDocument Shape
Every document returned from `listDocuments`/`getDocument` must include:
```typescript
{
externalId: string // Source-specific unique ID
title: string // Document title
content: string // Extracted plain text
mimeType: 'text/plain' // Always text/plain (content is extracted)
contentHash: string // SHA-256 of content (change detection)
sourceUrl?: string // Link back to original (stored on document record)
metadata?: Record<string, unknown> // Source-specific data (fed to mapTags)
}
```
## Content Hashing (Required)
The sync engine uses content hashes for change detection:
```typescript
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
}
```
## tagDefinitions — Declared Tag Definitions
Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names.
```typescript
tagDefinitions: [
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
{ id: 'version', displayName: 'Version', fieldType: 'number' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],
```
Each entry has:
- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`)
- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified")
- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from
Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`.
The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`.
## mapTags — Metadata to Semantic Keys
Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set.
The sync engine calls this automatically and translates semantic keys to actual DB slots
using the `tagSlotMapping` stored on the connector.
Return keys must match the `id` values declared in `tagDefinitions`.
```typescript
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
// Validate arrays before casting — metadata may be malformed
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
if (labels.length > 0) result.labels = labels.join(', ')
// Validate numbers — guard against NaN
if (metadata.version != null) {
const num = Number(metadata.version)
if (!Number.isNaN(num)) result.version = num
}
// Validate dates — guard against Invalid Date
if (typeof metadata.lastModified === 'string') {
const date = new Date(metadata.lastModified)
if (!Number.isNaN(date.getTime())) result.lastModified = date
}
return result
}
```
## External API Calls — Use `fetchWithRetry`
All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged.
For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max).
```typescript
import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils'
// Background sync — use defaults
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
// validateConfig — tighter retry budget
const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS)
```
## sourceUrl
If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path).
## Sync Engine Behavior (Do Not Modify)
The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It:
1. Calls `listDocuments` with pagination until `hasMore` is false
2. Compares `contentHash` to detect new/changed/unchanged documents
3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically
4. Handles soft-delete of removed documents
5. Resolves access tokens automatically — OAuth tokens are refreshed, API keys are decrypted from the `encryptedApiKey` column
You never need to modify the sync engine when adding a connector.
## Icon
The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain.
If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG.
## Registering
Add one line to `apps/sim/connectors/registry.ts`:
```typescript
import { {service}Connector } from '@/connectors/{service}'
export const CONNECTOR_REGISTRY: ConnectorRegistry = {
// ... existing connectors ...
{service}: {service}Connector,
}
```
## Reference Implementations
- **OAuth**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching
- **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth
## Checklist
- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig
- [ ] Created `connectors/{service}/index.ts` barrel export
- [ ] **Auth configured correctly:**
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
- API key: `auth.label` and `auth.placeholder` set appropriately
- [ ] `listDocuments` handles pagination and computes content hashes
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
- [ ] `metadata` includes source-specific data for tag mapping
- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags`
- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions)
- [ ] `validateConfig` verifies the source is accessible
- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`)
- [ ] All optional config fields validated in `validateConfig`
- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG)
- [ ] Registered in `connectors/registry.ts`

View File

@@ -0,0 +1,26 @@
---
description: SEO and GEO guidelines for the landing page
globs: ["apps/sim/app/(home)/**/*.tsx"]
---
# Landing Page — SEO / GEO
## SEO
- One `<h1>` per page, in Hero only — never add another.
- Strict heading hierarchy: H1 (Hero) → H2 (section titles) → H3 (feature names).
- Every section: `<section id="…" aria-labelledby="…-heading">`.
- Decorative/animated elements: `aria-hidden="true"`.
- All internal routes use Next.js `<Link>` (crawlable). External links get `rel="noopener noreferrer"`.
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element).
- Navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
- Feature lists must stay in sync with `WebApplication.featureList` in `structured-data.tsx`.
## GEO (Generative Engine Optimisation)
- **Answer-first pattern**: each section's H2 + subtitle should directly answer a user question (e.g. "What is Sim?", "How fast can I deploy?").
- **Atomic answer blocks**: each feature / template card should be independently extractable by an AI summariser.
- **Entity consistency**: always write "Sim" by name — never "the platform" or "our tool".
- **Keyword density**: first 150 visible chars of Hero must name "Sim", "AI agents", "agentic workflows".
- **sr-only summaries**: Hero and Templates each have a `<p className="sr-only">` (~50 words) as an atomic product/catalog summary for AI citation.
- **Specific numbers**: prefer concrete figures ("1,000+ integrations", "15+ AI providers") over vague claims.

View File

@@ -5,62 +5,122 @@ globs: ["apps/sim/hooks/queries/**/*.ts"]
# React Query Patterns
All React Query hooks live in `hooks/queries/`.
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
## Query Key Factory
Every query file defines a keys factory:
Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation:
```typescript
export const entityKeys = {
all: ['entity'] as const,
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
lists: () => [...entityKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
details: () => [...entityKeys.all, 'detail'] as const,
detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
}
```
Never use inline query keys — always use the factory.
## File Structure
```typescript
// 1. Query keys factory
// 2. Types (if needed)
// 3. Private fetch functions
// 3. Private fetch functions (accept signal parameter)
// 4. Exported hooks
```
## Query Hook
- Every `queryFn` must destructure and forward `signal` for request cancellation
- Every query must have an explicit `staleTime`
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
```typescript
async function fetchEntities(workspaceId: string, signal?: AbortSignal) {
const response = await fetch(`/api/entities?workspaceId=${workspaceId}`, { signal })
if (!response.ok) throw new Error('Failed to fetch entities')
return response.json()
}
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: entityKeys.list(workspaceId),
queryFn: () => fetchEntities(workspaceId as string),
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
placeholderData: keepPreviousData, // OK: workspaceId varies
})
}
```
## Mutation Hook
- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
- Invalidation must cover all affected query key prefixes (lists, details, related views)
```typescript
export function useCreateEntity() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables) => { /* fetch POST */ },
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
},
})
}
```
## Optimistic Updates
For optimistic mutations, use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error, ensuring the cache is always reconciled with the server.
```typescript
export function useUpdateEntity() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables) => { /* ... */ },
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic value */)
return { previous }
},
onError: (_err, variables, context) => {
queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
},
})
}
```
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
## useCallback Dependencies
Never include mutation objects (e.g., `createEntity`) in `useCallback` dependency arrays — the mutation object is not referentially stable and changes on every state update. The `.mutate()` and `.mutateAsync()` functions are stable in TanStack Query v5.
```typescript
// ✗ Bad — causes unnecessary recreations
const handler = useCallback(() => {
createEntity.mutate(data)
}, [createEntity]) // unstable reference
// ✓ Good — omit from deps, mutate is stable
const handler = useCallback(() => {
createEntity.mutate(data)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
```
## Naming
- **Keys**: `entityKeys`
- **Query hooks**: `useEntity`, `useEntityList`
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
- **Fetch functions**: `fetchEntity` (private)
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`, `useDeleteEntity`
- **Fetch functions**: `fetchEntity`, `fetchEntities` (private)

3
.gitignore vendored
View File

@@ -26,6 +26,9 @@ bun-debug.log*
**/standalone/
sim-standalone.tar.gz
# redis
dump.rdb
# misc
.DS_Store
*.pem

View File

@@ -134,21 +134,64 @@ Use `devtools` middleware. Use `persist` only when data should survive reload wi
## React Query
All React Query hooks live in `hooks/queries/`.
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
### Query Key Factory
Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation:
```typescript
export const entityKeys = {
all: ['entity'] as const,
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
lists: () => [...entityKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
details: () => [...entityKeys.all, 'detail'] as const,
detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
}
```
### Query Hooks
- Every `queryFn` must forward `signal` for request cancellation
- Every query must have an explicit `staleTime`
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
```typescript
export function useEntityList(workspaceId?: string) {
return useQuery({
queryKey: entityKeys.list(workspaceId),
queryFn: () => fetchEntities(workspaceId as string),
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
enabled: Boolean(workspaceId),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
placeholderData: keepPreviousData, // OK: workspaceId varies
})
}
```
### Mutation Hooks
- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
- For optimistic updates: use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error
- Don't include mutation objects in `useCallback` deps — `.mutate()` is stable in TanStack Query v5
```typescript
export function useUpdateEntity() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables) => { /* ... */ },
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic */)
return { previous }
},
onError: (_err, variables, context) => {
queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
},
})
}
```

View File

@@ -4,7 +4,7 @@
</a>
</p>
<p align="center">Build and deploy AI agent workflows in minutes.</p>
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
<p align="center">
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>

View File

@@ -233,6 +233,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
lang={lang}
breadcrumb={breadcrumbs}
/>
<style>{`#nd-page { grid-column: main-start / toc-end !important; max-width: 1400px !important; }`}</style>
<DocsPage
toc={data.toc}
breadcrumb={{
@@ -367,15 +368,17 @@ export async function generateMetadata(props: {
return {
title: data.title,
description:
data.description || 'Sim visual workflow builder for AI applications documentation',
data.description ||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
keywords: [
'AI workflow builder',
'visual workflow editor',
'AI automation',
'workflow automation',
'AI agents',
'no-code AI',
'drag and drop workflows',
'agentic workforce',
'AI agent platform',
'agentic workflows',
'LLM orchestration',
'AI automation',
'knowledge base',
'AI integrations',
data.title?.toLowerCase().split(' '),
]
.flat()
@@ -385,7 +388,8 @@ export async function generateMetadata(props: {
openGraph: {
title: data.title,
description:
data.description || 'Sim visual workflow builder for AI applications documentation',
data.description ||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
url: fullUrl,
siteName: 'Sim Documentation',
type: 'article',
@@ -406,7 +410,8 @@ export async function generateMetadata(props: {
card: 'summary_large_image',
title: data.title,
description:
data.description || 'Sim visual workflow builder for AI applications documentation',
data.description ||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
images: [ogImageUrl],
creator: '@simdotai',
site: '@simdotai',

View File

@@ -66,7 +66,7 @@ export default async function Layout({ children, params }: LayoutProps) {
'@type': 'WebSite',
name: 'Sim Documentation',
description:
'Comprehensive documentation for Sim - the visual workflow builder for AI Agent Workflows.',
'Documentation for Sim the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
url: 'https://docs.sim.ai',
publisher: {
'@type': 'Organization',

View File

@@ -552,16 +552,15 @@ video {
/* API Reference Pages — Mintlify-style overrides */
/* OpenAPI pages: span main + TOC grid columns for wide two-column layout.
The grid has columns: spacer | sidebar | main | toc | spacer.
By spanning columns 3-4, the article fills both main and toc areas,
while the grid structure stays identical to non-OpenAPI pages (no jitter). */
Use named grid lines from grid-template-areas so this works regardless
of whether the grid has 3 columns (production) or 5 columns (local dev). */
#nd-page:has(.api-page-header) {
grid-column: 3 / span 2 !important;
grid-column: main-start / toc-end !important;
max-width: 1400px !important;
}
/* Hide the empty TOC aside on OpenAPI pages so it doesn't overlay content */
#nd-docs-layout:has(#nd-page .api-page-header) #nd-toc {
#nd-docs-layout:has(#nd-page:has(.api-page-header)) #nd-toc {
display: none;
}
@@ -590,44 +589,39 @@ video {
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Method badge pills in page content — colored background pills */
#nd-page span.font-mono.font-medium[class*="text-green"] {
background-color: rgb(220 252 231 / 0.6);
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
/* Method badge pills — shared background colors (page + sidebar) */
span.font-mono.font-medium[data-method="get"],
span.font-mono.font-medium[data-method="head"],
span.font-mono.font-medium[data-method="options"] {
background-color: rgb(220 252 231 / 0.85);
}
html.dark #nd-page span.font-mono.font-medium[class*="text-green"] {
html.dark span.font-mono.font-medium[data-method="get"],
html.dark span.font-mono.font-medium[data-method="head"],
html.dark span.font-mono.font-medium[data-method="options"] {
background-color: rgb(34 197 94 / 0.15);
}
#nd-page span.font-mono.font-medium[class*="text-blue"] {
background-color: rgb(219 234 254 / 0.6);
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
span.font-mono.font-medium[data-method="post"] {
background-color: rgb(219 234 254 / 0.85);
}
html.dark #nd-page span.font-mono.font-medium[class*="text-blue"] {
html.dark span.font-mono.font-medium[data-method="post"] {
background-color: rgb(59 130 246 / 0.15);
}
#nd-page span.font-mono.font-medium[class*="text-orange"] {
background-color: rgb(255 237 213 / 0.6);
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
span.font-mono.font-medium[data-method="put"] {
background-color: rgb(254 249 195 / 0.85);
}
html.dark #nd-page span.font-mono.font-medium[class*="text-orange"] {
html.dark span.font-mono.font-medium[data-method="put"] {
background-color: rgb(234 179 8 / 0.15);
}
span.font-mono.font-medium[data-method="patch"] {
background-color: rgb(255 237 213 / 0.85);
}
html.dark span.font-mono.font-medium[data-method="patch"] {
background-color: rgb(249 115 22 / 0.15);
}
#nd-page span.font-mono.font-medium[class*="text-red"] {
background-color: rgb(254 226 226 / 0.6);
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
span.font-mono.font-medium[data-method="delete"] {
background-color: rgb(254 226 226 / 0.85);
}
html.dark #nd-page span.font-mono.font-medium[class*="text-red"] {
html.dark span.font-mono.font-medium[data-method="delete"] {
background-color: rgb(239 68 68 / 0.15);
}
@@ -635,52 +629,31 @@ html.dark #nd-page span.font-mono.font-medium[class*="text-red"] {
#nd-sidebar a:has(span.font-mono.font-medium) {
display: flex !important;
align-items: center !important;
gap: 6px;
gap: 0.375rem;
}
/* Sidebar method badges — ensure proper inline flex display */
/* Sidebar method badges — fixed-width for right-aligned labels */
#nd-sidebar a span.font-mono.font-medium {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
font-size: 10px !important;
width: 2.625rem;
font-size: 0.625rem !important;
line-height: 1 !important;
padding: 2.5px 4px;
border-radius: 3px;
padding: 0.15625rem 0.25rem;
border-radius: 0.1875rem;
flex-shrink: 0;
}
/* Sidebar GET badges */
#nd-sidebar a span.font-mono.font-medium[class*="text-green"] {
background-color: rgb(220 252 231 / 0.6);
}
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-green"] {
background-color: rgb(34 197 94 / 0.15);
}
/* Sidebar POST badges */
#nd-sidebar a span.font-mono.font-medium[class*="text-blue"] {
background-color: rgb(219 234 254 / 0.6);
}
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-blue"] {
background-color: rgb(59 130 246 / 0.15);
}
/* Sidebar PUT badges */
#nd-sidebar a span.font-mono.font-medium[class*="text-orange"] {
background-color: rgb(255 237 213 / 0.6);
}
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-orange"] {
background-color: rgb(249 115 22 / 0.15);
}
/* Sidebar DELETE badges */
#nd-sidebar a span.font-mono.font-medium[class*="text-red"] {
background-color: rgb(254 226 226 / 0.6);
}
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-red"] {
background-color: rgb(239 68 68 / 0.15);
/* Footer navigation method badges — pill styling to match sidebar */
#nd-page span.font-mono.font-medium[data-method] {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.625rem !important;
line-height: 1 !important;
padding: 0.15625rem 0.375rem;
border-radius: 0.1875rem;
}
/* Code block containers — match regular docs styling */
@@ -740,8 +713,25 @@ html.dark
font-size: 0.6875rem !important;
letter-spacing: 0.025em;
text-transform: uppercase;
padding: 0.125rem 0.5rem !important;
border-radius: 0.375rem !important;
}
/* POST — softer blue */
/* Path bar per-method colors (fumadocs renders these, so we match by class) */
/* GET */
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-green"] {
color: rgb(22 163 74) !important;
background-color: rgb(220 252 231 / 0.7) !important;
}
html.dark
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-green"] {
color: rgb(74 222 128) !important;
background-color: rgb(34 197 94 / 0.15) !important;
}
/* POST */
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-blue"] {
@@ -755,19 +745,47 @@ html.dark
color: rgb(96 165 250) !important;
background-color: rgb(59 130 246 / 0.15) !important;
}
/* GET — softer green */
/* PUT */
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-green"] {
color: rgb(22 163 74) !important;
background-color: rgb(220 252 231 / 0.7) !important;
span.font-mono.font-medium[class*="text-yellow"] {
color: rgb(161 98 7) !important;
background-color: rgb(254 249 195 / 0.7) !important;
}
html.dark
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-green"] {
color: rgb(74 222 128) !important;
background-color: rgb(34 197 94 / 0.15) !important;
span.font-mono.font-medium[class*="text-yellow"] {
color: rgb(250 204 21) !important;
background-color: rgb(234 179 8 / 0.15) !important;
}
/* PATCH */
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-orange"] {
color: rgb(194 65 12) !important;
background-color: rgb(255 237 213 / 0.7) !important;
}
html.dark
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-orange"] {
color: rgb(251 146 60) !important;
background-color: rgb(249 115 22 / 0.15) !important;
}
/* DELETE */
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-red"] {
color: rgb(185 28 28) !important;
background-color: rgb(254 226 226 / 0.7) !important;
}
html.dark
#nd-page:has(.api-page-header)
div.flex.flex-row.items-center.rounded-xl.border.not-prose
span.font-mono.font-medium[class*="text-red"] {
color: rgb(248 113 113) !important;
background-color: rgb(239 68 68 / 0.15) !important;
}
/* Path text inside method+path bar — monospace, bright like Gumloop */
@@ -966,17 +984,17 @@ html.dark .response-section-dropdown-item:hover {
order: 1;
}
/* Type badge — order 2, grey pill like Mintlify */
/* Type badge — order 2, grey pill */
#nd-page:has(.api-page-header)
.flex.flex-wrap.items-center.gap-3.not-prose
> span.text-sm.font-mono.text-fd-muted-foreground {
order: 2;
background-color: rgb(240 240 243);
color: rgb(100 100 110);
padding: 0.125rem 0.5rem;
background-color: rgb(241 245 249);
color: rgb(71 85 105);
padding: 0.1875rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.6875rem;
line-height: 1.25rem;
line-height: 1.125rem;
font-weight: 500;
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
@@ -984,8 +1002,8 @@ html.dark
#nd-page:has(.api-page-header)
.flex.flex-wrap.items-center.gap-3.not-prose
> span.text-sm.font-mono.text-fd-muted-foreground {
background-color: rgb(39 39 42);
color: rgb(212 212 216);
background-color: rgb(51 51 56);
color: rgb(212 212 220);
}
/* Hide the "*" inside the name span — we'll add "required" as a ::after on the flex row */
@@ -993,26 +1011,26 @@ html.dark
display: none;
}
/* Required badge — order 3, light red pill */
/* Required badge — order 3, red pill */
#nd-page:has(.api-page-header)
.flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after {
content: "required";
order: 3;
display: inline-flex;
align-items: center;
background-color: rgb(254 235 235);
color: rgb(220 38 38);
padding: 0.125rem 0.5rem;
background-color: rgb(254 226 226);
color: rgb(185 28 28);
padding: 0.1875rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.6875rem;
line-height: 1.25rem;
line-height: 1.125rem;
font-weight: 500;
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
html.dark
#nd-page:has(.api-page-header)
.flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after {
background-color: rgb(127 29 29 / 0.2);
background-color: rgb(153 27 27 / 0.3);
color: rgb(252 165 165);
}
@@ -1054,12 +1072,12 @@ html.dark
> span.text-sm.font-mono.text-fd-muted-foreground::after {
content: "string";
font-size: 0.6875rem;
line-height: 1.25rem;
line-height: 1.125rem;
font-weight: 500;
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
background-color: rgb(240 240 243);
color: rgb(100 100 110);
padding: 0.125rem 0.5rem;
background-color: rgb(241 245 249);
color: rgb(71 85 105);
padding: 0.1875rem 0.5rem;
border-radius: 0.375rem;
display: inline-flex;
align-items: center;
@@ -1069,8 +1087,8 @@ html.dark
div.my-4
> .flex.flex-wrap.items-center.gap-3.not-prose
> span.text-sm.font-mono.text-fd-muted-foreground::after {
background-color: rgb(39 39 42);
color: rgb(212 212 216);
background-color: rgb(51 51 56);
color: rgb(212 212 220);
}
/* "header" badge via ::before on the auth flex row */
@@ -1079,12 +1097,12 @@ html.dark
order: 3;
display: inline-flex;
align-items: center;
background-color: rgb(240 240 243);
color: rgb(100 100 110);
padding: 0.125rem 0.5rem;
background-color: rgb(241 245 249);
color: rgb(71 85 105);
padding: 0.1875rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.6875rem;
line-height: 1.25rem;
line-height: 1.125rem;
font-weight: 500;
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
@@ -1092,22 +1110,22 @@ html.dark
#nd-page:has(.api-page-header)
div.my-4
> .flex.flex-wrap.items-center.gap-3.not-prose::before {
background-color: rgb(39 39 42);
color: rgb(212 212 216);
background-color: rgb(51 51 56);
color: rgb(212 212 220);
}
/* "required" badge via ::after on the auth flex row — light red pill */
/* "required" badge via ::after on the auth flex row — red pill */
#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::after {
content: "required";
order: 4;
display: inline-flex;
align-items: center;
background-color: rgb(254 235 235);
color: rgb(220 38 38);
padding: 0.125rem 0.5rem;
background-color: rgb(254 226 226);
color: rgb(185 28 28);
padding: 0.1875rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.6875rem;
line-height: 1.25rem;
line-height: 1.125rem;
font-weight: 500;
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
@@ -1115,7 +1133,7 @@ html.dark
#nd-page:has(.api-page-header)
div.my-4
> .flex.flex-wrap.items-center.gap-3.not-prose::after {
background-color: rgb(127 29 29 / 0.2);
background-color: rgb(153 27 27 / 0.3);
color: rgb(252 165 165);
}
@@ -1168,12 +1186,12 @@ html.dark #nd-page:has(.api-page-header) .text-sm.border-t {
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > button,
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > span:has(> button) {
order: 2;
background-color: rgb(240 240 243);
color: rgb(100 100 110);
padding: 0.125rem 0.5rem;
background-color: rgb(241 245 249);
color: rgb(71 85 105);
padding: 0.1875rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.6875rem;
line-height: 1.25rem;
line-height: 1.125rem;
font-weight: 500;
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
@@ -1182,8 +1200,8 @@ html.dark
#nd-page:has(.api-page-header)
.flex.flex-wrap.items-center.gap-3.not-prose
> span:has(> button) {
background-color: rgb(39 39 42);
color: rgb(212 212 216);
background-color: rgb(51 51 56);
color: rgb(212 212 220);
}
/* Section headings (Authorization, Path Parameters, etc.) — consistent top spacing */

View File

@@ -7,26 +7,27 @@ export default function RootLayout({ children }: { children: ReactNode }) {
export const metadata = {
metadataBase: new URL('https://docs.sim.ai'),
title: {
default: 'Sim Documentation - Visual Workflow Builder for AI Applications',
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
template: '%s',
},
description:
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
'Documentation for Sim the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
keywords: [
'AI workflow builder',
'visual workflow editor',
'AI automation',
'workflow automation',
'AI agents',
'no-code AI',
'drag and drop workflows',
'agentic workforce',
'AI agent platform',
'open-source AI agents',
'agentic workflows',
'LLM orchestration',
'AI integrations',
'workflow canvas',
'AI Agent Workflow Builder',
'workflow orchestration',
'agent builder',
'AI workflow automation',
'visual programming',
'knowledge base',
'AI automation',
'workflow builder',
'AI workflow orchestration',
'enterprise AI',
'AI agent deployment',
'intelligent automation',
'AI tools',
],
authors: [{ name: 'Sim Team', url: 'https://sim.ai' }],
creator: 'Sim',
@@ -53,9 +54,9 @@ export const metadata = {
alternateLocale: ['es_ES', 'fr_FR', 'de_DE', 'ja_JP', 'zh_CN'],
url: 'https://docs.sim.ai',
siteName: 'Sim Documentation',
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
description:
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
'Documentation for Sim the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
images: [
{
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation',
@@ -67,9 +68,9 @@ export const metadata = {
},
twitter: {
card: 'summary_large_image',
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
description:
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
'Documentation for Sim the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
creator: '@simdotai',
site: '@simdotai',
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation'],

View File

@@ -37,9 +37,9 @@ export async function GET() {
const manifest = `# Sim Documentation
> Visual Workflow Builder for AI Applications
> The open-source platform to build AI agents and run your agentic workforce.
Sim is a visual workflow builder for AI applications that lets you build AI agent workflows visually. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders.
## Documentation Overview

View File

@@ -10,9 +10,9 @@ export function TOCFooter() {
<div className='text-balance font-semibold text-base leading-tight'>
Start building today
</div>
<div className='text-muted-foreground'>Trusted by over 70,000 builders.</div>
<div className='text-muted-foreground'>Trusted by over 100,000 builders.</div>
<div className='text-muted-foreground'>
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
The open-source platform to build AI agents and run your agentic workforce.
</div>
<Link
href='https://sim.ai/signup'

View File

@@ -74,7 +74,7 @@ export function StructuredData({
name: 'Sim Documentation',
url: baseUrl,
description:
'Comprehensive documentation for Sim visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
publisher: {
'@type': 'Organization',
name: 'Sim',
@@ -98,7 +98,7 @@ export function StructuredData({
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
description:
'Visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
url: baseUrl,
author: {
'@type': 'Organization',
@@ -109,12 +109,13 @@ export function StructuredData({
category: 'Developer Tools',
},
featureList: [
'Visual workflow builder with drag-and-drop interface',
'AI agent creation and automation',
'80+ built-in integrations',
'Real-time team collaboration',
'Multiple deployment options',
'Custom integrations via MCP protocol',
'AI agent creation',
'Agentic workflow orchestration',
'1,000+ integrations',
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Knowledge base creation',
'Table creation',
'Document creation',
],
}

View File

@@ -11,6 +11,9 @@
"(generated)/workflows",
"(generated)/logs",
"(generated)/usage",
"(generated)/audit-logs"
"(generated)/audit-logs",
"(generated)/tables",
"(generated)/files",
"(generated)/knowledge-bases"
]
}

View File

@@ -190,13 +190,8 @@ console.log(`${processedItems} gültige Elemente verarbeitet`);
### Einschränkungen
<Callout type="warning">
Container-Blöcke (Schleifen und Parallele) können nicht ineinander verschachtelt werden. Das bedeutet:
- Du kannst keinen Schleifenblock in einen anderen Schleifenblock platzieren
- Du kannst keinen Parallel-Block in einen Schleifenblock platzieren
- Du kannst keinen Container-Block in einen anderen Container-Block platzieren
Wenn du mehrdimensionale Iterationen benötigst, erwäge eine Umstrukturierung deines Workflows, um sequentielle Schleifen zu verwenden oder Daten in Stufen zu verarbeiten.
<Callout type="info">
Container-Blöcke (Schleifen und Parallele) unterstützen Verschachtelung. Du kannst Schleifen in Schleifen, Parallele in Schleifen und jede Kombination von Container-Blöcken platzieren, um komplexe mehrdimensionale Workflows zu erstellen.
</Callout>
<Callout type="info">

View File

@@ -142,11 +142,8 @@ Jede parallele Instanz läuft unabhängig:
### Einschränkungen
<Callout type="warning">
Container-Blöcke (Schleifen und Parallele) können nicht ineinander verschachtelt werden. Das bedeutet:
- Sie können keinen Schleifenblock in einen Parallelblock platzieren
- Sie können keinen weiteren Parallelblock in einen Parallelblock platzieren
- Sie können keinen Container-Block in einen anderen Container-Block platzieren
<Callout type="info">
Container-Blöcke (Schleifen und Parallele) unterstützen Verschachtelung. Sie können Parallele in Parallele, Schleifen in Parallele und jede Kombination von Container-Blöcken platzieren, um komplexe mehrdimensionale Workflows zu erstellen.
</Callout>
<Callout type="info">

View File

@@ -11,6 +11,9 @@
"(generated)/workflows",
"(generated)/logs",
"(generated)/usage",
"(generated)/audit-logs"
"(generated)/audit-logs",
"(generated)/tables",
"(generated)/files",
"(generated)/knowledge-bases"
]
}

View File

@@ -184,13 +184,8 @@ Variables (i=0) → Loop (While i<10) → Agent (Process) → Variables (i++)
### Limitations
<Callout type="warning">
Container blocks (Loops and Parallels) cannot be nested inside each other. This means:
- You cannot place a Loop block inside another Loop block
- You cannot place a Parallel block inside a Loop block
- You cannot place any container block inside another container block
If you need multi-dimensional iteration, consider restructuring your workflow to use sequential loops or process data in stages.
<Callout type="info">
Container blocks (Loops and Parallels) support nesting. You can place loops inside loops, parallels inside loops, and any combination of container blocks to build complex multi-dimensional workflows.
</Callout>
<Callout type="info">

View File

@@ -148,11 +148,8 @@ Each parallel instance runs independently:
### Limitations
<Callout type="warning">
Container blocks (Loops and Parallels) cannot be nested inside each other. This means:
- You cannot place a Loop block inside a Parallel block
- You cannot place another Parallel block inside a Parallel block
- You cannot place any container block inside another container block
<Callout type="info">
Container blocks (Loops and Parallels) support nesting. You can place parallels inside parallels, loops inside parallels, and any combination of container blocks to build complex multi-dimensional workflows.
</Callout>
<Callout type="info">

View File

@@ -0,0 +1,143 @@
---
title: Connectors
description: Automatically sync documents from external sources into your knowledge base
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
Connectors let you pull documents directly from external services into your knowledge base. Instead of manually uploading files, a connector continuously syncs content from sources like Notion, Google Drive, GitHub, Slack, and more — keeping your knowledge base up to date automatically.
## Available Connectors
Sim ships with 20+ built-in connectors spanning productivity tools, cloud storage, development platforms, and more.
| Category | Connectors |
|----------|-----------|
| **Productivity** | Notion, Confluence, Asana, Linear, Jira |
| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint |
| **Documents** | Google Docs, WordPress, Webflow |
| **Development** | GitHub |
| **Communication** | Slack |
| **CRM** | HubSpot, Salesforce |
| **Data** | Airtable |
| **Note-taking** | Evernote, Obsidian |
| **Meetings** | Fireflies |
## Adding a Connector
<Steps>
<Step>
### Select a source
Open a knowledge base and click **Add Connector**. You'll see the full list of available connectors — pick the service you want to sync from.
</Step>
<Step>
### Authenticate
Most connectors use **OAuth** — select an existing credential from the dropdown, or click **Connect new account** to authorize through the service's login flow. Tokens are refreshed automatically, so you won't need to re-authenticate unless you revoke access.
A few connectors (Evernote, Obsidian, Fireflies) use **API keys** instead. Paste your key or developer token directly, and it will be stored securely.
<Callout type="info">
If you rotate an API key in the external service, you'll need to update it in Sim as well. OAuth tokens are refreshed automatically, but API keys are not.
</Callout>
</Step>
<Step>
### Configure
Each connector has its own configuration fields that control what gets synced. Some examples:
- **Notion**: Choose between syncing an entire workspace, a specific database, or a single page tree
- **GitHub**: Specify a repository, branch, and optional file extension filter
- **Confluence**: Enter your Atlassian domain and optionally filter by space key or content type
- **Obsidian**: Provide your vault URL and optionally restrict to a folder path
All configuration is validated when you save — if a repository doesn't exist or a domain is unreachable, you'll get an immediate error.
</Step>
<Step>
### Choose sync frequency
Select how often the connector should re-sync:
| Frequency | Description |
|-----------|-------------|
| Every hour | Best for fast-moving sources |
| Every 6 hours | Good balance for most use cases |
| **Daily** (default) | Suitable for content that changes infrequently |
| Weekly | For stable, rarely-updated sources |
| Manual only | Sync only when you trigger it |
</Step>
<Step>
### Configure metadata tags (optional)
If the connector supports metadata tags, you'll see checkboxes for each tag type (e.g., Labels, Last Modified, Notebook). All are enabled by default — uncheck any you don't need.
See the [Metadata Tags](#metadata-tags) section below for details.
</Step>
<Step>
### Connect & Sync
Click **Connect & Sync** to save the connector and trigger the first sync immediately. Documents will begin appearing in your knowledge base as they are processed.
</Step>
</Steps>
## How Syncing Works
On each sync, the connector fetches documents from the external service and compares them against what's already in your knowledge base. Only documents that have actually changed are reprocessed — new content is added, updated content is re-chunked and re-embedded, and documents that no longer exist in the source are removed.
This means syncing is efficient even for large document sets. A connector with thousands of documents will only do meaningful work when something changes.
### Handling Failures
If a single document fails to fetch (e.g., due to a permission issue or timeout), the sync continues with the remaining documents. The failed document will be retried on the next sync cycle.
If an entire sync fails (e.g., the service is down or credentials expired), the connector automatically backs off and retries later. The backoff resets as soon as a sync succeeds.
## Metadata Tags
Connectors can automatically populate [tags](/docs/knowledgebase/tags) with metadata from the source, letting you filter documents in the Knowledge block based on information from the external service.
For example, a Notion connector might tag documents with their **Labels**, **Last Modified** date, and **Created** date. A GitHub connector might tag documents with their **Repository** and **File Path**. This metadata becomes available for [tag-based filtering](/docs/knowledgebase/tags) in your workflows.
### Opting Out
You can disable specific metadata tags during connector setup. Disabled tags won't be populated, leaving those tag slots available for other connectors or manual tagging.
<Callout type="info">
Tag slots are shared across all documents in a knowledge base. If you have multiple connectors, each one's metadata tags draw from the same pool of available slots.
</Callout>
## Excluding Documents
You can manually exclude specific documents from a connector's sync. Excluded documents are skipped on every subsequent sync, even if they change in the source. This is useful for filtering out templates, drafts, or other content you don't want in your knowledge base.
## Source Links
Every synced document retains a link back to the original in the external service. This lets you trace any knowledge base document to its source — whether that's a Notion page, a GitHub file, a Confluence article, or a Slack conversation.
## Multiple Connectors
You can add multiple connectors to a single knowledge base. For example, you might sync internal documentation from Confluence alongside code from GitHub and meeting notes from Fireflies — all searchable together through the Knowledge block.
Each connector manages its own documents independently. Metadata tag slots are shared across the knowledge base, so keep an eye on slot usage if you're combining several connectors that each populate tags.
## Common Use Cases
- **Internal knowledge base**: Sync your team's Notion workspace and Confluence spaces so AI agents can answer questions about internal processes, policies, and documentation
- **Customer support**: Connect HubSpot or Salesforce alongside your help docs from WordPress or Google Docs to give support agents full context on customers and product information
- **Engineering assistant**: Sync a GitHub repository and Jira or Linear issues so an AI agent can reference code, specs, and ticket history when answering developer questions
- **Meeting intelligence**: Pull in Fireflies transcripts alongside Slack conversations to build a searchable archive of decisions and discussions
- **Research and notes**: Sync Evernote notebooks or an Obsidian vault to make your personal notes available to AI workflows

View File

@@ -1,4 +1,4 @@
{
"title": "Knowledgebase",
"pages": ["index", "tags"]
"pages": ["index", "connectors", "tags"]
}

View File

@@ -204,4 +204,37 @@ Update multiple existing records in an Airtable table
| ↳ `recordCount` | number | Number of records updated |
| ↳ `updatedRecordIds` | array | List of updated record IDs |
### `airtable_list_bases`
List all bases the authenticated user has access to
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `bases` | json | Array of Airtable bases with id, name, and permissionLevel |
| `metadata` | json | Operation metadata including total bases count |
### `airtable_get_base_schema`
Get the schema of all tables, fields, and views in an Airtable base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tables` | json | Array of table schemas with fields and views |
| `metadata` | json | Operation metadata including total tables count |

View File

@@ -25,7 +25,7 @@ In Sim, the Knowledge Base block enables your agents to perform intelligent sema
## Usage Instructions
Integrate Knowledge into the workflow. Can search, upload chunks, and create documents.
Integrate Knowledge into the workflow. Perform full CRUD operations on documents, chunks, and tags.
@@ -122,4 +122,161 @@ Create a new document in a knowledge base
| `message` | string | Success or error message describing the operation result |
| `documentId` | string | ID of the created document |
### `knowledge_list_tags`
List all tag definitions for a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base to list tags for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `knowledgeBaseId` | string | ID of the knowledge base |
| `tags` | array | Array of tag definitions for the knowledge base |
| ↳ `id` | string | Tag definition ID |
| ↳ `tagSlot` | string | Internal tag slot \(e.g. tag1, number1\) |
| ↳ `displayName` | string | Human-readable tag name |
| ↳ `fieldType` | string | Tag field type \(text, number, date, boolean\) |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `totalTags` | number | Total number of tag definitions |
### `knowledge_list_documents`
List documents in a knowledge base with optional filtering, search, and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base to list documents from |
| `search` | string | No | Search query to filter documents by filename |
| `enabledFilter` | string | No | Filter by enabled status: "all", "enabled", or "disabled" |
| `limit` | number | No | Maximum number of documents to return \(default: 50\) |
| `offset` | number | No | Number of documents to skip for pagination \(default: 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `knowledgeBaseId` | string | ID of the knowledge base |
| `documents` | array | Array of documents in the knowledge base |
| ↳ `id` | string | Document ID |
| ↳ `filename` | string | Document filename |
| ↳ `fileSize` | number | File size in bytes |
| ↳ `mimeType` | string | MIME type of the document |
| ↳ `enabled` | boolean | Whether the document is enabled |
| ↳ `processingStatus` | string | Processing status \(pending, processing, completed, failed\) |
| ↳ `chunkCount` | number | Number of chunks in the document |
| ↳ `tokenCount` | number | Total token count across chunks |
| ↳ `uploadedAt` | string | Upload timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `totalDocuments` | number | Total number of documents matching the filter |
| `limit` | number | Page size used |
| `offset` | number | Offset used for pagination |
### `knowledge_delete_document`
Delete a document from a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `documentId` | string | Yes | ID of the document to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `documentId` | string | ID of the deleted document |
| `message` | string | Confirmation message |
### `knowledge_list_chunks`
List chunks for a document in a knowledge base with optional filtering and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base |
| `documentId` | string | Yes | ID of the document to list chunks from |
| `search` | string | No | Search query to filter chunks by content |
| `enabled` | string | No | Filter by enabled status: "true", "false", or "all" \(default: "all"\) |
| `limit` | number | No | Maximum number of chunks to return \(1-100, default: 50\) |
| `offset` | number | No | Number of chunks to skip for pagination \(default: 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `knowledgeBaseId` | string | ID of the knowledge base |
| `documentId` | string | ID of the document |
| `chunks` | array | Array of chunks in the document |
| ↳ `id` | string | Chunk ID |
| ↳ `chunkIndex` | number | Index of the chunk within the document |
| ↳ `content` | string | Chunk text content |
| ↳ `contentLength` | number | Content length in characters |
| ↳ `tokenCount` | number | Token count for the chunk |
| ↳ `enabled` | boolean | Whether the chunk is enabled |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `totalChunks` | number | Total number of chunks matching the filter |
| `limit` | number | Page size used |
| `offset` | number | Offset used for pagination |
### `knowledge_update_chunk`
Update the content or enabled status of a chunk in a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base |
| `documentId` | string | Yes | ID of the document containing the chunk |
| `chunkId` | string | Yes | ID of the chunk to update |
| `content` | string | No | New content for the chunk |
| `enabled` | boolean | No | Whether the chunk should be enabled or disabled |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `documentId` | string | ID of the parent document |
| `id` | string | Chunk ID |
| `chunkIndex` | number | Index of the chunk within the document |
| `content` | string | Updated chunk content |
| `contentLength` | number | Content length in characters |
| `tokenCount` | number | Token count for the chunk |
| `enabled` | boolean | Whether the chunk is enabled |
| `updatedAt` | string | Last update timestamp |
### `knowledge_delete_chunk`
Delete a chunk from a document in a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base |
| `documentId` | string | Yes | ID of the document containing the chunk |
| `chunkId` | string | Yes | ID of the chunk to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `chunkId` | string | ID of the deleted chunk |
| `documentId` | string | ID of the parent document |
| `message` | string | Confirmation message |

View File

@@ -183,13 +183,8 @@ while (count < items.length) {
### Limitaciones
<Callout type="warning">
Los bloques contenedores (Bucles y Paralelos) no pueden anidarse unos dentro de otros. Esto significa:
- No puedes colocar un bloque de Bucle dentro de otro bloque de Bucle
- No puedes colocar un bloque Paralelo dentro de un bloque de Bucle
- No puedes colocar ningún bloque contenedor dentro de otro bloque contenedor
Si necesitas iteración multidimensional, considera reestructurar tu flujo de trabajo para usar bucles secuenciales o procesar datos por etapas.
<Callout type="info">
Los bloques contenedores (Bucles y Paralelos) admiten anidamiento. Puedes colocar bucles dentro de bucles, paralelos dentro de bucles, y cualquier combinación de bloques contenedores para construir flujos de trabajo multidimensionales complejos.
</Callout>
<Callout type="info">

View File

@@ -117,11 +117,8 @@ Cada instancia paralela se ejecuta de forma independiente:
### Limitaciones
<Callout type="warning">
Los bloques contenedores (Bucles y Paralelos) no pueden anidarse unos dentro de otros. Esto significa:
- No puedes colocar un bloque de Bucle dentro de un bloque Paralelo
- No puedes colocar otro bloque Paralelo dentro de un bloque Paralelo
- No puedes colocar ningún bloque contenedor dentro de otro bloque contenedor
<Callout type="info">
Los bloques contenedores (Bucles y Paralelos) admiten anidamiento. Puedes colocar paralelos dentro de paralelos, bucles dentro de paralelos, y cualquier combinación de bloques contenedores para construir flujos de trabajo multidimensionales complejos.
</Callout>
<Callout type="info">

View File

@@ -190,13 +190,8 @@ return results;
### Limitations
<Callout type="warning">
Les blocs conteneurs (Boucles et Parallèles) ne peuvent pas être imbriqués les uns dans les autres. Cela signifie :
- Vous ne pouvez pas placer un bloc Boucle à l'intérieur d'un autre bloc Boucle
- Vous ne pouvez pas placer un bloc Parallèle à l'intérieur d'un bloc Boucle
- Vous ne pouvez pas placer un bloc conteneur à l'intérieur d'un autre bloc conteneur
Si vous avez besoin d'une itération multidimensionnelle, envisagez de restructurer votre flux de travail pour utiliser des boucles séquentielles ou traiter les données par étapes.
<Callout type="info">
Les blocs conteneurs (Boucles et Parallèles) prennent en charge l'imbrication. Vous pouvez placer des boucles dans des boucles, des parallèles dans des boucles, et toute combinaison de blocs conteneurs pour construire des flux de travail multidimensionnels complexes.
</Callout>
<Callout type="info">

View File

@@ -117,11 +117,8 @@ Chaque instance parallèle s'exécute indépendamment :
### Limitations
<Callout type="warning">
Les blocs conteneurs (Boucles et Parallèles) ne peuvent pas être imbriqués les uns dans les autres. Cela signifie :
- Vous ne pouvez pas placer un bloc de Boucle à l'intérieur d'un bloc Parallèle
- Vous ne pouvez pas placer un autre bloc Parallèle à l'intérieur d'un bloc Parallèle
- Vous ne pouvez pas placer un bloc conteneur à l'intérieur d'un autre bloc conteneur
<Callout type="info">
Les blocs conteneurs (Boucles et Parallèles) prennent en charge l'imbrication. Vous pouvez placer des parallèles dans des parallèles, des boucles dans des parallèles, et toute combinaison de blocs conteneurs pour construire des flux de travail multidimensionnels complexes.
</Callout>
<Callout type="info">

View File

@@ -198,13 +198,8 @@ while (counter < items.length && !foundTarget) {
### 制限事項
<Callout type="warning">
コンテナブロック(ループと並列)は互いに入れ子にすることができません。つまり:
- ループブロックを別のループブロック内に配置することはできません
- 並列ブロックをループブロック内に配置することはできません
- どのコンテナブロックも別のコンテナブロック内に配置することはできません
多次元反復が必要な場合は、順次ループを使用するか、データを段階的に処理するようにワークフローを再構成することを検討してください。
<Callout type="info">
コンテナブロック(ループと並列)はネストをサポートしています。ループの中にループを、ループの中に並列を、そして任意のコンテナブロックの組み合わせで複雑な多次元ワークフローを構築できます。
</Callout>
<Callout type="info">

View File

@@ -117,11 +117,8 @@ Parallel (["gpt-4o", "claude-3.7-sonnet", "gemini-2.5-pro"]) → Agent → Evalu
### 制限事項
<Callout type="warning">
コンテナブロック(ループと並列)は互いにネストできません。つまり:
- 並列ブロック内にループブロックを配置できません
- 並列ブロック内に別の並列ブロックを配置できません
- どのコンテナブロック内にも別のコンテナブロックを配置できません
<Callout type="info">
コンテナブロック(ループと並列)はネストをサポートしています。並列の中に並列を、並列の中にループを、そして任意のコンテナブロックの組み合わせで複雑な多次元ワークフローを構築できます。
</Callout>
<Callout type="info">

View File

@@ -160,13 +160,8 @@ Variables (i=0) → Loop (While i<10) → Agent (Process) → Variables (i++)
### 限制
<Callout type="warning">
容器块(循环和并行)不能嵌套在彼此内部。这意味着:
- 您不能将一个循环块放入另一个循环块中
- 您不能将一个并行块放入循环块中
- 您不能将任何容器块放入另一个容器块中
如果您需要多维迭代,请考虑重构您的工作流以使用顺序循环或分阶段处理数据。
<Callout type="info">
容器块(循环和并行)支持嵌套。您可以将循环放入循环中、将并行放入循环中,以及任意组合容器块来构建复杂的多维工作流。
</Callout>
<Callout type="info">

View File

@@ -121,11 +121,8 @@ const allResults = input.parallel.results;
### 限制
<Callout type="warning">
容器块(循环和并行)不能嵌套在彼此内部。这意味着:
- 您不能在并行块中放置循环块
- 您不能在并行块中放置另一个并行块
- 您不能在一个容器块中放置另一个容器块
<Callout type="info">
容器块(循环和并行)支持嵌套。您可以将并行放入并行中、将循环放入并行中,以及任意组合容器块来构建复杂的多维工作流。
</Callout>
<Callout type="info">

View File

@@ -62,7 +62,10 @@ function openapiPluginBadgeLeft() {
null,
createElement(
'span',
{ className: `font-mono font-medium me-1.5 text-[10px] text-nowrap ${colorClass}` },
{
className: `font-mono font-medium me-1.5 text-[10px] text-nowrap ${colorClass}`,
'data-method': method.toLowerCase(),
},
method
),
node.name

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,11 @@
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
"start": "next start",
"postinstall": "fumadocs-mdx",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format ."
},
"dependencies": {
"@sim/db": "workspace:*",

View File

@@ -0,0 +1,37 @@
'use client'
import { useEffect } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
function isColorDark(hexColor: string): boolean {
const hex = hexColor.replace('#', '')
const r = Number.parseInt(hex.substr(0, 2), 16)
const g = Number.parseInt(hex.substr(2, 2), 16)
const b = Number.parseInt(hex.substr(4, 2), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
}
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
useEffect(() => {
const rootStyle = getComputedStyle(document.documentElement)
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
if (brandBackground && isColorDark(brandBackground)) {
document.body.classList.add('auth-dark-bg')
} else {
document.body.classList.remove('auth-dark-bg')
}
}, [])
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
<Nav hideAuthButtons={true} variant='auth' />
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>
</main>
</AuthBackground>
)
}

View File

@@ -1,42 +1,10 @@
'use client'
import type { Metadata } from 'next'
import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
import { useEffect } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
// Helper to detect if a color is dark
function isColorDark(hexColor: string): boolean {
const hex = hexColor.replace('#', '')
const r = Number.parseInt(hex.substr(0, 2), 16)
const g = Number.parseInt(hex.substr(2, 2), 16)
const b = Number.parseInt(hex.substr(4, 2), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
export default function AuthLayout({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Check if brand background is dark and add class accordingly
const rootStyle = getComputedStyle(document.documentElement)
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
if (brandBackground && isColorDark(brandBackground)) {
document.body.classList.add('auth-dark-bg')
} else {
document.body.classList.remove('auth-dark-bg')
}
}, [])
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
{/* Header - Nav handles all conditional logic */}
<Nav hideAuthButtons={true} variant='auth' />
{/* Content */}
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>
</main>
</AuthBackground>
)
return <AuthLayoutClient>{children}</AuthLayoutClient>
}

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form'
export const metadata: Metadata = {
title: 'Log In',
}
export const dynamic = 'force-dynamic'
export default async function LoginPage() {

View File

@@ -1,117 +1,8 @@
'use client'
import type { Metadata } from 'next'
import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content'
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
const logger = createLogger('ResetPasswordPage')
function ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [isSubmitting, setIsSubmitting] = useState(false)
const [statusMessage, setStatusMessage] = useState<{
type: 'success' | 'error' | null
text: string
}>({
type: null,
text: '',
})
useEffect(() => {
if (!token) {
setStatusMessage({
type: 'error',
text: 'Invalid or missing reset token. Please request a new password reset link.',
})
}
}, [token])
const handleResetPassword = async (password: string) => {
try {
setIsSubmitting(true)
setStatusMessage({ type: null, text: '' })
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
newPassword: password,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to reset password')
}
setStatusMessage({
type: 'success',
text: 'Password reset successful! Redirecting to login...',
})
setTimeout(() => {
router.push('/login?resetSuccess=true')
}, 1500)
} catch (error) {
logger.error('Error resetting password:', { error })
setStatusMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to reset password',
})
} finally {
setIsSubmitting(false)
}
}
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Reset your password
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Enter a new password for your account
</p>
</div>
<div className={`${inter.className} mt-8`}>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
/>
</div>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<Link
href='/login'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Back to login
</Link>
</div>
</>
)
export const metadata: Metadata = {
title: 'Reset Password',
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
>
<ResetPasswordContent />
</Suspense>
)
}
export default ResetPasswordPage

View File

@@ -0,0 +1,117 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
const logger = createLogger('ResetPasswordPage')
function ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [isSubmitting, setIsSubmitting] = useState(false)
const [statusMessage, setStatusMessage] = useState<{
type: 'success' | 'error' | null
text: string
}>({
type: null,
text: '',
})
useEffect(() => {
if (!token) {
setStatusMessage({
type: 'error',
text: 'Invalid or missing reset token. Please request a new password reset link.',
})
}
}, [token])
const handleResetPassword = async (password: string) => {
try {
setIsSubmitting(true)
setStatusMessage({ type: null, text: '' })
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
newPassword: password,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to reset password')
}
setStatusMessage({
type: 'success',
text: 'Password reset successful! Redirecting to login...',
})
setTimeout(() => {
router.push('/login?resetSuccess=true')
}, 1500)
} catch (error) {
logger.error('Error resetting password:', { error })
setStatusMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to reset password',
})
} finally {
setIsSubmitting(false)
}
}
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Reset your password
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Enter a new password for your account
</p>
</div>
<div className={`${inter.className} mt-8`}>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
/>
</div>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<Link
href='/login'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Back to login
</Link>
</div>
</>
)
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
>
<ResetPasswordContent />
</Suspense>
)
}

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import SignupForm from '@/app/(auth)/signup/signup-form'
export const metadata: Metadata = {
title: 'Sign Up',
}
export const dynamic = 'force-dynamic'
export default async function SignupPage() {

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
title: 'Single Sign-On',
}
export const dynamic = 'force-dynamic'
export default async function SSOPage() {

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
import { hasEmailService } from '@/lib/messaging/email/mailer'
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
export const metadata: Metadata = {
title: 'Verify Email',
}
export const dynamic = 'force-dynamic'
export default function VerifyPage() {

View File

@@ -43,7 +43,7 @@ function VerificationForm({
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
return () => clearTimeout(timer)
}
if (countdown === 0 && isResendDisabled) {

View File

@@ -0,0 +1,332 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
interface DotGridProps {
className?: string
cols: number
rows: number
gap?: number
}
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
return (
<div
aria-hidden='true'
className={className}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap,
placeItems: 'center',
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
))}
</div>
)
}
const CURSOR_KEYFRAMES = `
@keyframes cursorVikhyath {
0% { transform: translate(0, 0); }
12% { transform: translate(120px, 10px); }
24% { transform: translate(80px, 80px); }
36% { transform: translate(-10px, 60px); }
48% { transform: translate(-15px, -20px); }
60% { transform: translate(100px, -40px); }
72% { transform: translate(180px, 30px); }
84% { transform: translate(50px, 50px); }
100% { transform: translate(0, 0); }
}
@keyframes cursorAlexa {
0% { transform: translate(0, 0); }
14% { transform: translate(45px, -35px); }
28% { transform: translate(-75px, 20px); }
42% { transform: translate(25px, -50px); }
57% { transform: translate(-65px, 15px); }
71% { transform: translate(35px, -30px); }
85% { transform: translate(-30px, -10px); }
100% { transform: translate(0, 0); }
}
@media (prefers-reduced-motion: reduce) {
@keyframes cursorVikhyath { 0%, 100% { transform: none; } }
@keyframes cursorAlexa { 0%, 100% { transform: none; } }
}
`
const CURSOR_ARROW_PATH =
'M17.135 2.198L12.978 14.821C12.478 16.339 10.275 16.16 10.028 14.581L9.106 8.703C9.01 8.092 8.554 7.599 7.952 7.457L1.591 5.953C0 5.577 0.039 3.299 1.642 2.978L15.39 0.229C16.534 0 17.499 1.09 17.135 2.198Z'
const CURSOR_ARROW_MIRRORED_PATH =
'M0.365 2.198L4.522 14.821C5.022 16.339 7.225 16.16 7.472 14.58L8.394 8.702C8.49 8.091 8.946 7.599 9.548 7.456L15.909 5.953C17.5 5.577 17.461 3.299 15.857 2.978L2.11 0.228C0.966 0 0.001 1.09 0.365 2.198Z'
function CursorArrow({ fill }: { fill: string }) {
return (
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
<path d={fill === '#2ABBF8' ? CURSOR_ARROW_PATH : CURSOR_ARROW_MIRRORED_PATH} fill={fill} />
</svg>
)
}
function VikhyathCursor() {
return (
<div
aria-hidden='true'
className='pointer-events-none absolute'
style={{
top: '27.47%',
left: '25%',
animation: 'cursorVikhyath 16s ease-in-out infinite',
willChange: 'transform',
}}
>
<div className='relative h-[37.14px] w-[79.18px]'>
<div className='absolute top-0 left-[56.02px]'>
<CursorArrow fill='#2ABBF8' />
</div>
<div className='-left-[4px] absolute top-[18px] flex items-center rounded bg-[#2ABBF8] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
Vikhyath
</div>
</div>
</div>
)
}
function AlexaCursor() {
return (
<div
aria-hidden='true'
className='pointer-events-none absolute'
style={{
top: '66.80%',
left: '49%',
animation: 'cursorAlexa 13s ease-in-out infinite',
willChange: 'transform',
}}
>
<div className='relative h-[35.09px] w-[62.16px]'>
<div className='absolute top-0 left-0'>
<CursorArrow fill='#FFCC02' />
</div>
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#FFCC02] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
Alexa
</div>
</div>
</div>
)
}
interface YouCursorProps {
x: number
y: number
visible: boolean
}
function YouCursor({ x, y, visible }: YouCursorProps) {
if (!visible) return null
return (
<div
aria-hidden='true'
className='pointer-events-none fixed z-50'
style={{
left: x,
top: y,
transform: 'translate(-2px, -2px)',
}}
>
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
<path d={CURSOR_ARROW_MIRRORED_PATH} fill='#33C482' />
</svg>
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#33C482] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
You
</div>
</div>
)
}
/**
* Collaboration section — team workflows and real-time collaboration.
*
* SEO:
* - `<section id="collaboration" aria-labelledby="collaboration-heading">`.
* - `<h2 id="collaboration-heading">` for the section title.
* - Product visuals use `<figure>` with `<figcaption>` and descriptive `alt` text.
*
* GEO:
* - Name specific capabilities (version control, shared workspaces, RBAC, audit logs).
* - Lead with a summary so AI can answer "Does Sim support team collaboration?".
* - Reference "Sim" by name per capability ("Sim's real-time collaboration").
*/
const CURSOR_LERP_FACTOR = 0.3
export default function Collaboration() {
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 })
const [isHovering, setIsHovering] = useState(false)
const sectionRef = useRef<HTMLElement>(null)
const targetPos = useRef({ x: 0, y: 0 })
const animationRef = useRef<number>(0)
useEffect(() => {
const animate = () => {
setCursorPos((prev) => ({
x: prev.x + (targetPos.current.x - prev.x) * CURSOR_LERP_FACTOR,
y: prev.y + (targetPos.current.y - prev.y) * CURSOR_LERP_FACTOR,
}))
animationRef.current = requestAnimationFrame(animate)
}
if (isHovering) {
animationRef.current = requestAnimationFrame(animate)
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [isHovering])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
targetPos.current = { x: e.clientX, y: e.clientY }
}, [])
const handleMouseEnter = useCallback((e: React.MouseEvent) => {
targetPos.current = { x: e.clientX, y: e.clientY }
setCursorPos({ x: e.clientX, y: e.clientY })
setIsHovering(true)
}, [])
const handleMouseLeave = useCallback(() => {
setIsHovering(false)
}, [])
return (
<section
ref={sectionRef}
id='collaboration'
aria-labelledby='collaboration-heading'
className='bg-[#1C1C1C]'
style={{ cursor: isHovering ? 'none' : 'auto' }}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<YouCursor x={cursorPos.x} y={cursorPos.y} visible={isHovering} />
<style dangerouslySetInnerHTML={{ __html: CURSOR_KEYFRAMES }} />
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
rows={1}
gap={6}
/>
<div className='relative overflow-hidden'>
<Link
href='/studio/multiplayer'
target='_blank'
rel='noopener noreferrer'
className='absolute bottom-10 left-4 z-20 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:left-8 md:left-[80px]'
>
<div className='relative h-7 w-11 shrink-0'>
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
</div>
<div className='flex flex-col gap-[2px]'>
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
Blog
</span>
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
How we built realtime collaboration
</span>
</div>
</Link>
<div className='grid grid-cols-[auto_1fr]'>
<div className='flex flex-col items-start gap-3 px-4 pt-[100px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px]'>
<Badge
variant='blue'
size='md'
dot
className='bg-[#33C482]/10 font-season text-[#33C482] uppercase tracking-[0.02em]'
>
Teams
</Badge>
<h2
id='collaboration-heading'
className='font-[430] font-season text-[32px] text-white leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
>
Realtime
<br />
collaboration
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
Grab your team. Build agents together <br /> in real-time inside your workspace.
</p>
<Link
href='/signup'
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
>
Build together
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
</Link>
</div>
<figure className='pointer-events-none relative h-[600px] w-full'>
<div className='-left-[18%] absolute inset-y-0 min-w-full'>
<Image
src='/landing/collaboration-visual.svg'
alt='Collaboration visual showing team workflows with real-time editing, shared cursors, and version control interface'
width={876}
height={480}
className='h-full w-auto min-w-[100vw] object-left'
priority
/>
</div>
<div className='hidden lg:block'>
<VikhyathCursor />
<AlexaCursor />
</div>
<figcaption className='sr-only'>
Sim collaboration interface with real-time cursors, shared workspace, and team
presence indicators
</figcaption>
</figure>
</div>
</div>
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
rows={1}
gap={6}
/>
</section>
)
}

View File

@@ -0,0 +1,17 @@
/**
* Enterprise section — compliance, scale, and security messaging.
*
* SEO:
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
* - `<h2 id="enterprise-heading">` for the section title.
* - Compliance certs (SOC2, HIPAA) as visible `<strong>` text.
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
*
* GEO:
* - Entity-rich: "Sim is SOC2 and HIPAA compliant" — not "We are compliant."
* - `<ul>` checklist of features (SSO, RBAC, audit logs, SLA, on-premise deployment)
* as an atomic answer block for "What enterprise features does Sim offer?".
*/
export default function Enterprise() {
return null
}

View File

@@ -0,0 +1,229 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { Badge } from '@/components/emcn'
function hexToRgba(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16)
const g = Number.parseInt(hex.slice(3, 5), 16)
const b = Number.parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
const FEATURE_TABS = [
{
label: 'Integrations',
color: '#FA4EDF',
segments: [
[0.3, 8],
[0.25, 10],
[0.45, 12],
[0.5, 8],
[0.65, 10],
[0.8, 12],
[0.75, 8],
[0.95, 10],
[1, 12],
[0.85, 10],
],
},
{
label: 'Copilot',
color: '#2ABBF8',
segments: [
[0.25, 12],
[0.4, 10],
[0.35, 8],
[0.55, 12],
[0.7, 10],
[0.85, 8],
[1, 14],
[0.9, 12],
[1, 14],
],
},
{
label: 'Models',
color: '#00F701',
badgeColor: '#22C55E',
segments: [
[0.2, 6],
[0.35, 10],
[0.3, 8],
[0.5, 10],
[0.6, 8],
[0.75, 12],
[0.85, 10],
[1, 8],
[0.9, 12],
[1, 10],
[0.95, 6],
],
},
{
label: 'Deploy',
color: '#FFCC02',
badgeColor: '#EAB308',
segments: [
[0.3, 12],
[0.25, 8],
[0.4, 10],
[0.55, 10],
[0.7, 8],
[0.6, 10],
[0.85, 12],
[1, 10],
[0.9, 10],
[1, 10],
],
},
{
label: 'Logs',
color: '#FF6B35',
segments: [
[0.25, 10],
[0.35, 8],
[0.3, 10],
[0.5, 10],
[0.65, 8],
[0.8, 12],
[0.9, 10],
[1, 10],
[0.85, 12],
[1, 10],
],
},
{
label: 'Knowledge Base',
color: '#8B5CF6',
segments: [
[0.3, 10],
[0.25, 8],
[0.4, 10],
[0.5, 10],
[0.65, 10],
[0.8, 10],
[0.9, 12],
[1, 10],
[0.95, 10],
[1, 10],
],
},
]
function DotGrid({
cols,
rows,
width,
borderLeft,
}: {
cols: number
rows: number
width?: number
borderLeft?: boolean
}) {
return (
<div
aria-hidden='true'
className={`shrink-0 bg-[#FDFDFD] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
style={{
width: width ? `${width}px` : undefined,
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap: 4,
placeItems: 'center',
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#DEDEDE]' />
))}
</div>
)
}
export default function Features() {
const [activeTab, setActiveTab] = useState(0)
return (
<section
id='features'
aria-labelledby='features-heading'
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
>
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
<Image
src='/landing/features-transition.svg'
alt=''
width={1440}
height={366}
className='h-auto w-full'
priority
/>
</div>
<div className='relative z-10 pt-[100px]'>
<div className='flex flex-col items-start gap-[20px] px-[80px]'>
<Badge
variant='blue'
size='md'
dot
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
style={{
color: FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
backgroundColor: hexToRgba(
FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
0.1
),
}}
>
Features
</Badge>
<h2
id='features-heading'
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
>
Power your AI workforce
</h2>
</div>
<div className='mt-[73px] flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
<DotGrid cols={10} rows={8} width={80} />
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
{FEATURE_TABS.map((tab, index) => (
<button
key={tab.label}
type='button'
role='tab'
aria-selected={index === activeTab}
onClick={() => setActiveTab(index)}
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
>
{tab.label}
{index === activeTab && (
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
{tab.segments.map(([opacity, width], i) => (
<div
key={i}
className='h-full shrink-0'
style={{
width: `${width}%`,
backgroundColor: tab.color,
opacity,
}}
/>
))}
</div>
)}
</button>
))}
</div>
<DotGrid cols={10} rows={8} width={80} borderLeft />
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,18 @@
/**
* Landing page footer — navigation, legal links, and entity reinforcement.
*
* SEO:
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
* - External links include `rel="noopener noreferrer"`.
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
*
* GEO:
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
*/
export default function Footer() {
return null
}

View File

@@ -0,0 +1,584 @@
'use client'
import { useEffect, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
/** Stagger between each block appearing (seconds). */
const ENTER_STAGGER = 0.06
/** Duration of each block's fade-in (seconds). */
const ENTER_DURATION = 0.3
/** Stagger between each block disappearing (seconds). */
const EXIT_STAGGER = 0.12
/** Duration of each block's fade-out (seconds). */
const EXIT_DURATION = 0.5
/** Shared corner radius for all decorative rects. */
const RX = '2.59574'
/** Hold time after the initial enter animation before cycling starts (ms). */
const INITIAL_HOLD_MS = 2500
/** Pause between an exit completing and the next enter starting (ms). */
const TRANSITION_PAUSE_MS = 400
/** Hold time between successive transitions (ms). */
const HOLD_BETWEEN_MS = 2500
/** Animation state for a block group. */
export type BlockAnimState = 'entering' | 'visible' | 'exiting' | 'hidden'
/** Positions around the hero where block groups can appear. */
export type BlockPosition = 'topRight' | 'left' | 'rightEdge' | 'rightSide' | 'topLeft'
/** Attributes for a single animated SVG rect. */
interface BlockRect {
opacity: number
width: string
height: string
fill: string
x?: string
y?: string
transform?: string
}
const containerVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: ENTER_STAGGER } },
exit: { transition: { staggerChildren: EXIT_STAGGER } },
}
const blockVariants: Variants = {
hidden: { opacity: 0, transition: { duration: 0 } },
visible: (targetOpacity: number) => ({
opacity: targetOpacity,
transition: { duration: ENTER_DURATION },
}),
exit: {
opacity: 0,
transition: { duration: EXIT_DURATION },
},
}
/** Maps a BlockAnimState to the framer-motion animate value. */
function toAnimateValue(state: BlockAnimState): string {
if (state === 'entering' || state === 'visible') return 'visible'
if (state === 'exiting') return 'exit'
return 'hidden'
}
/** Shared SVG wrapper that staggers child rects in and out. */
function AnimatedBlocksSvg({
width,
height,
viewBox,
rects,
animState = 'entering',
}: {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
animState?: BlockAnimState
}) {
return (
<motion.svg
width={width}
height={height}
viewBox={viewBox}
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
initial='hidden'
animate={toAnimateValue(animState)}
variants={containerVariants}
>
{rects.map((r, i) => (
<motion.rect
key={i}
variants={blockVariants}
custom={r.opacity}
x={r.x}
y={r.y}
width={r.width}
height={r.height}
rx={RX}
fill={r.fill}
transform={r.transform}
/>
))}
</motion.svg>
)
}
/**
* Rect data for the top-right position.
* Two-row horizontal strip, ordered left-to-right.
*/
const TOP_RIGHT_RECTS: readonly BlockRect[] = [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
]
/**
* Rect data for the top-left position.
* Same two-row structure as top-right with rotated colour palette:
* blue→green, green→yellow, yellow→pink, pink→blue.
*/
const TOP_LEFT_RECTS: readonly BlockRect[] = [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#FFCC02' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#FFCC02' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#FFCC02' },
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
]
/**
* Rect data for the left position.
* Two-column vertical strip, ordered top-to-bottom.
*/
const LEFT_RECTS: readonly BlockRect[] = [
{
opacity: 0.6,
width: '34.240',
height: '33.725',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.480',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 0)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 17.378)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.986',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 51.616)',
},
{
opacity: 0.6,
width: '16.8626',
height: '140.507',
fill: '#00F701',
transform: 'matrix(-1 0 0 1 33.986 85.335)',
},
{
opacity: 0.4,
x: '17.119',
y: '136.962',
width: '34.240',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 1,
x: '17.119',
y: '136.962',
width: '16.8626',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 0.5,
width: '34.240',
height: '33.725',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
]
/**
* Rect data for the right-side position (right edge of screenshot).
* Same two-column structure as left with rotated colours:
* pink→blue, green→pink, yellow→green.
*/
const RIGHT_SIDE_RECTS: readonly BlockRect[] = [
{
opacity: 0.6,
width: '34.240',
height: '33.725',
fill: '#2ABBF8',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.480',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.727 0)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.727 17.378)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.986',
fill: '#2ABBF8',
transform: 'matrix(0 1 1 0 0 51.616)',
},
{
opacity: 0.6,
width: '16.8626',
height: '140.507',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.986 85.335)',
},
{
opacity: 0.4,
x: '17.119',
y: '136.962',
width: '34.240',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 1,
x: '17.119',
y: '136.962',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 0.5,
width: '34.240',
height: '33.725',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
]
/**
* Rect data for the right-edge position (far right of screen).
* Two-column vertical strip, ordered top-to-bottom.
*/
const RIGHT_RECTS: readonly BlockRect[] = [
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '34.241',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 16.891 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.482',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.739 16.888)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 33.776)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.739 34.272)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.012 68.510)',
},
{
opacity: 0.6,
width: '16.8626',
height: '102.384',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.787 102.384)',
},
{
opacity: 0.4,
x: '17.131',
y: '153.859',
width: '34.241',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
},
{
opacity: 1,
x: '17.131',
y: '153.859',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
},
]
/** Number of rects per position, used to compute animation durations. */
const RECT_COUNTS: Record<BlockPosition, number> = {
topRight: TOP_RIGHT_RECTS.length,
topLeft: TOP_LEFT_RECTS.length,
left: LEFT_RECTS.length,
rightSide: RIGHT_SIDE_RECTS.length,
rightEdge: RIGHT_RECTS.length,
}
/** Total enter animation time for a position (seconds). */
function enterTime(pos: BlockPosition): number {
return (RECT_COUNTS[pos] - 1) * ENTER_STAGGER + ENTER_DURATION
}
/** Total exit animation time for a position (seconds). */
function exitTime(pos: BlockPosition): number {
return (RECT_COUNTS[pos] - 1) * EXIT_STAGGER + EXIT_DURATION
}
/** A single step in the repeating animation cycle. */
type CycleStep =
| { action: 'exit'; position: BlockPosition }
| { action: 'enter'; position: BlockPosition }
| { action: 'hold'; ms: number }
/**
* The repeating cycle sequence. After all steps, the layout returns to its
* initial state (topRight + left + rightEdge) so the loop is seamless.
*
* Order: exit top → exit right-edge → enter right-side-of-preview →
* exit left → enter top-left → exit right-side → enter left →
* exit top-left → enter top-right → enter right-edge → back to initial.
*/
const CYCLE_STEPS: readonly CycleStep[] = [
{ action: 'exit', position: 'topRight' },
{ action: 'exit', position: 'rightEdge' },
{ action: 'enter', position: 'rightSide' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'exit', position: 'left' },
{ action: 'enter', position: 'topLeft' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'exit', position: 'rightSide' },
{ action: 'enter', position: 'left' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'exit', position: 'topLeft' },
{ action: 'enter', position: 'topRight' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'enter', position: 'rightEdge' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
]
/**
* Drives the block-cycling animation loop. Returns the current animation
* state for every position so each component can be driven declaratively.
*
* Lifecycle:
* 1. All three initial groups (topRight, left, rightEdge) enter together.
* 2. After a hold period the cycle begins, processing each step in order.
* 3. Repeats indefinitely, returning to the initial layout every cycle.
*/
export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
const [states, setStates] = useState<Record<BlockPosition, BlockAnimState>>({
topRight: 'entering',
left: 'entering',
rightEdge: 'entering',
rightSide: 'hidden',
topLeft: 'hidden',
})
useEffect(() => {
const cancelled = { current: false }
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const run = async () => {
const longestEnter = Math.max(
enterTime('topRight'),
enterTime('left'),
enterTime('rightEdge')
)
await delay(longestEnter * 1000)
if (cancelled.current) return
setStates({
topRight: 'visible',
left: 'visible',
rightEdge: 'visible',
rightSide: 'hidden',
topLeft: 'hidden',
})
await delay(INITIAL_HOLD_MS)
if (cancelled.current) return
while (!cancelled.current) {
for (const step of CYCLE_STEPS) {
if (cancelled.current) return
if (step.action === 'exit') {
setStates((prev) => ({ ...prev, [step.position]: 'exiting' }))
await delay(exitTime(step.position) * 1000)
if (cancelled.current) return
setStates((prev) => ({ ...prev, [step.position]: 'hidden' }))
await delay(TRANSITION_PAUSE_MS)
} else if (step.action === 'enter') {
setStates((prev) => ({ ...prev, [step.position]: 'entering' }))
await delay(enterTime(step.position) * 1000)
if (cancelled.current) return
setStates((prev) => ({ ...prev, [step.position]: 'visible' }))
await delay(TRANSITION_PAUSE_MS)
} else {
await delay(step.ms)
}
if (cancelled.current) return
}
}
}
run()
return () => {
cancelled.current = true
}
}, [])
return states
}
interface AnimatedBlockProps {
animState?: BlockAnimState
}
/** Two-row horizontal strip at the top-right of the hero. */
export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={295}
height={34}
viewBox='0 0 295 34'
rects={TOP_RIGHT_RECTS}
animState={animState}
/>
)
}
/** Two-row horizontal strip at the top-left of the hero. */
export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={295}
height={34}
viewBox='0 0 295 34'
rects={TOP_LEFT_RECTS}
animState={animState}
/>
)
}
/** Two-column vertical strip on the left edge of the screenshot. */
export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
height={226}
viewBox='0 0 34 226.021'
rects={LEFT_RECTS}
animState={animState}
/>
)
}
/** Two-column vertical strip on the right edge of the screenshot. */
export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
height={226}
viewBox='0 0 34 226.021'
rects={RIGHT_SIDE_RECTS}
animState={animState}
/>
)
}
/** Two-column vertical strip at the far-right edge of the screen. */
export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
height={205}
viewBox='0 0 34 204.769'
rects={RIGHT_RECTS}
animState={animState}
/>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
import {
BlocksLeftAnimated,
BlocksRightAnimated,
BlocksRightSideAnimated,
BlocksTopLeftAnimated,
BlocksTopRightAnimated,
useBlockCycle,
} from '@/app/(home)/components/hero/components/animated-blocks'
const LandingPreview = dynamic(
() =>
import('@/app/(home)/components/landing-preview/landing-preview').then(
(mod) => mod.LandingPreview
),
{
ssr: false,
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[#1b1b1b]' />,
}
)
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
const CTA_BASE =
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
export default function Hero() {
const blockStates = useBlockCycle()
return (
<section
id='hero'
aria-labelledby='hero-heading'
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
>
<p className='sr-only'>
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
1,000+ integrations and LLMs including OpenAI, Claude, Gemini, Mistral, and xAI to
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
HIPAA compliant.
</p>
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw]'
>
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
</div>
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
>
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
</div>
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
<h1
id='hero-heading'
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
>
Build Agents
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
Build and deploy agentic workflows
</p>
<div className='mt-[12px] flex items-center gap-[8px]'>
<Link
href='/login'
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
</div>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
>
<BlocksTopRightAnimated animState={blockStates.topRight} />
</div>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
>
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
</div>
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
>
<BlocksLeftAnimated animState={blockStates.left} />
</div>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
>
<BlocksRightSideAnimated animState={blockStates.rightSide} />
</div>
<div className='relative z-10 overflow-hidden rounded border border-[#2A2A2A]'>
<LandingPreview />
</div>
</div>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
>
<BlocksRightAnimated animState={blockStates.rightEdge} />
</div>
</section>
)
}

View File

@@ -0,0 +1,23 @@
import Collaboration from '@/app/(home)/components/collaboration/collaboration'
import Enterprise from '@/app/(home)/components/enterprise/enterprise'
import Features from '@/app/(home)/components/features/features'
import Footer from '@/app/(home)/components/footer/footer'
import Hero from '@/app/(home)/components/hero/hero'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Pricing from '@/app/(home)/components/pricing/pricing'
import StructuredData from '@/app/(home)/components/structured-data'
import Templates from '@/app/(home)/components/templates/templates'
import Testimonials from '@/app/(home)/components/testimonials/testimonials'
export {
Collaboration,
Enterprise,
Features,
Footer,
Hero,
Navbar,
Pricing,
StructuredData,
Templates,
Testimonials,
}

View File

@@ -0,0 +1,153 @@
'use client'
import { memo, useCallback, useRef, useState } from 'react'
import { ArrowUp } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createPortal } from 'react-dom'
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
/**
* Lightweight static panel replicating the real workspace panel styling.
* The copilot tab is active with a functional user input.
* When submitted, stores the prompt and redirects to /signup (same as landing hero).
*
* Structure mirrors the real Panel component:
* aside > div.border-l.pt-[14px] > Header(px-8) > Tabs(px-8,pt-14) > Content(pt-12)
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
*/
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
const router = useRouter()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
LandingPromptStorage.store(inputValue)
router.push('/signup')
}, [isEmpty, inputValue, router])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
return (
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-[14px]'>
{/* Header — More + Chat | Deploy + Run */}
<div className='flex flex-shrink-0 items-center justify-between px-[8px]'>
<div className='pointer-events-none flex gap-[6px]'>
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
</div>
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
</div>
</div>
<Link
href='/signup'
className='flex gap-[6px]'
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setCursorPos(null)}
>
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
</div>
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
</div>
</Link>
{cursorPos &&
createPortal(
<div
className='pointer-events-none fixed z-[9999]'
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
>
{/* Decorative color bars — mirrors hero top-right block sequence */}
<div className='flex h-[4px]'>
<div className='h-full w-[8px] bg-[#2ABBF8]' />
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
<div className='h-full w-[8px] bg-[#00F701]' />
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
<div className='h-full w-[8px] bg-[#FFCC02]' />
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
<div className='h-full w-[8px] bg-[#FA4EDF]' />
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
</div>
<div className='flex items-center gap-[5px] bg-white px-[6px] py-[4px] font-medium text-[#1C1C1C] text-[11px]'>
Get started
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
</div>
</div>,
document.body
)}
</div>
{/* Tabs */}
<div className='flex flex-shrink-0 items-center px-[8px] pt-[14px]'>
<div className='pointer-events-none flex gap-[4px]'>
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-[8px] py-[5px]'>
<span className='font-medium text-[#e6e6e6] text-[12.5px]'>Copilot</span>
</div>
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
<span className='font-medium text-[#787878] text-[12.5px]'>Toolbar</span>
</div>
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
<span className='font-medium text-[#787878] text-[12.5px]'>Editor</span>
</div>
</div>
</div>
{/* Tab content — copilot */}
<div className='flex flex-1 flex-col overflow-hidden pt-[12px]'>
<div className='flex h-full flex-col'>
{/* Copilot header bar — matches mx-[-1px] in real copilot */}
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-[12px] py-[6px]'>
<span className='truncate font-medium text-[#e6e6e6] text-[14px]'>New Chat</span>
</div>
{/* User input — matches real UserInput at p-[8px] inside copilot welcome state */}
<div className='px-[8px] pt-[12px] pb-[8px]'>
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-[6px] py-[6px]'>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='Build an AI agent...'
rows={2}
className='mb-[6px] min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-[2px] py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
/>
<div className='flex items-center justify-end'>
<button
type='button'
onClick={handleSubmit}
disabled={isEmpty}
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#808080' : '#e0e0e0',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,142 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Database, Layout, Search, Settings } from 'lucide-react'
import { ChevronDown, Library } from '@/components/emcn'
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/**
* Props for the LandingPreviewSidebar component
*/
interface LandingPreviewSidebarProps {
workflows: PreviewWorkflow[]
activeWorkflowId: string
onSelectWorkflow: (id: string) => void
}
/**
* Static footer navigation items matching the real sidebar
*/
const FOOTER_NAV_ITEMS = [
{ id: 'logs', label: 'Logs', icon: Library },
{ id: 'templates', label: 'Templates', icon: Layout },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const
/**
* Lightweight static sidebar replicating the real workspace sidebar styling.
* Only workflow items are interactive — everything else is pointer-events-none.
*
* Colors sourced from the dark theme CSS variables:
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
*/
export function LandingPreviewSidebar({
workflows,
activeWorkflowId,
onSelectWorkflow,
}: LandingPreviewSidebarProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const handleToggle = useCallback(() => {
setIsDropdownOpen((prev) => !prev)
}, [])
useEffect(() => {
if (!isDropdownOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isDropdownOpen])
return (
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
{/* Header */}
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
<div className='flex items-center justify-between'>
<button
type='button'
onClick={handleToggle}
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
>
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
<ChevronDown
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
<div className='pointer-events-none flex flex-shrink-0 items-center'>
<Search className='h-[14px] w-[14px] text-[#787878]' />
</div>
</div>
{/* Workspace switcher dropdown */}
{isDropdownOpen && (
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
<div
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
role='menuitem'
onClick={() => setIsDropdownOpen(false)}
>
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
</div>
</div>
)}
</div>
{/* Workflow items */}
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
{workflows.map((workflow) => {
const isActive = workflow.id === activeWorkflowId
return (
<button
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
}`}
>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
style={{ backgroundColor: workflow.color }}
/>
<div className='min-w-0 flex-1'>
<div
className={`min-w-0 truncate text-left font-medium ${
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
}`}
>
{workflow.name}
</div>
</div>
</button>
)
})}
</div>
{/* Footer navigation — static */}
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
{FOOTER_NAV_ITEMS.map((item) => {
const Icon = item.icon
return (
<div
key={item.id}
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import ReactFlow, {
applyEdgeChanges,
applyNodeChanges,
type Edge,
type EdgeProps,
type EdgeTypes,
getSmoothStepPath,
type Node,
type NodeTypes,
type OnEdgesChange,
type OnNodesChange,
ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { PreviewBlockNode } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
import {
EASE_OUT,
type PreviewWorkflow,
toReactFlowElements,
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
interface FitViewOptions {
padding?: number
maxZoom?: number
}
interface LandingPreviewWorkflowProps {
workflow: PreviewWorkflow
animate?: boolean
fitViewOptions?: FitViewOptions
}
/**
* Custom edge that draws left-to-right on initial load via stroke animation.
* Falls back to a static path when `data.animate` is false.
*/
function PreviewEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
data,
}: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
if (data?.animate) {
return (
<motion.path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
opacity: { duration: 0.15, delay: data.delay ?? 0 },
}}
/>
)
}
return (
<path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
/>
)
}
const NODE_TYPES: NodeTypes = { previewBlock: PreviewBlockNode }
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
const PRO_OPTIONS = { hideAttribution: true }
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1 } as const
/**
* Inner flow component. Keyed on workflow ID by the parent so it remounts
* cleanly on workflow switch — fitView fires on mount with zero delay.
*/
function PreviewFlow({ workflow, animate = false, fitViewOptions }: LandingPreviewWorkflowProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => toReactFlowElements(workflow, animate),
[workflow, animate]
)
const [nodes, setNodes] = useState<Node[]>(initialNodes)
const [edges, setEdges] = useState<Edge[]>(initialEdges)
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
)
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
)
const resolvedFitViewOptions = fitViewOptions ?? DEFAULT_FIT_VIEW_OPTIONS
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{ type: 'previewEdge' }}
elementsSelectable={false}
nodesDraggable
nodesConnectable={false}
zoomOnScroll={false}
zoomOnDoubleClick={false}
panOnScroll={false}
zoomOnPinch={false}
panOnDrag
preventScrolling={false}
autoPanOnNodeDrag={false}
proOptions={PRO_OPTIONS}
fitView
fitViewOptions={resolvedFitViewOptions}
className='h-full w-full bg-[#1b1b1b]'
/>
)
}
/**
* Lightweight ReactFlow canvas displaying an interactive workflow preview.
* The key on workflow.id forces a clean remount on switch — instant fitView,
* no timers, no flicker.
*/
export function LandingPreviewWorkflow({
workflow,
animate = false,
fitViewOptions,
}: LandingPreviewWorkflowProps) {
return (
<div className='h-full w-full'>
<ReactFlowProvider key={workflow.id}>
<PreviewFlow workflow={workflow} animate={animate} fitViewOptions={fitViewOptions} />
</ReactFlowProvider>
</div>
)
}

View File

@@ -0,0 +1,307 @@
'use client'
import { memo } from 'react'
import { motion } from 'framer-motion'
import { Database } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import {
AgentIcon,
AnthropicIcon,
FirecrawlIcon,
GeminiIcon,
GithubIcon,
GmailIcon,
GoogleCalendarIcon,
GoogleSheetsIcon,
JiraIcon,
LinearIcon,
LinkedInIcon,
MistralIcon,
NotionIcon,
OpenAIIcon,
RedditIcon,
ReductoIcon,
ScheduleIcon,
SlackIcon,
StartIcon,
SupabaseIcon,
TelegramIcon,
TextractIcon,
WebhookIcon,
xAIIcon,
xIcon,
YouTubeIcon,
} from '@/components/icons'
import {
BLOCK_STAGGER,
EASE_OUT,
type PreviewTool,
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/** Map block type strings to their icon components. */
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
starter: StartIcon,
start_trigger: StartIcon,
agent: AgentIcon,
slack: SlackIcon,
jira: JiraIcon,
x: xIcon,
youtube: YouTubeIcon,
schedule: ScheduleIcon,
telegram: TelegramIcon,
knowledge_base: Database,
webhook: WebhookIcon,
github: GithubIcon,
supabase: SupabaseIcon,
google_calendar: GoogleCalendarIcon,
gmail: GmailIcon,
google_sheets: GoogleSheetsIcon,
linear: LinearIcon,
firecrawl: FirecrawlIcon,
reddit: RedditIcon,
notion: NotionIcon,
reducto: ReductoIcon,
textract: TextractIcon,
linkedin: LinkedInIcon,
}
/** Model prefix → provider icon for the "Model" row in agent blocks. */
const MODEL_PROVIDER_ICONS: Array<{
prefix: string
icon: React.ComponentType<{ className?: string }>
size?: string
}> = [
{ prefix: 'gpt-', icon: OpenAIIcon },
{ prefix: 'o3', icon: OpenAIIcon },
{ prefix: 'o4', icon: OpenAIIcon },
{ prefix: 'claude-', icon: AnthropicIcon },
{ prefix: 'gemini-', icon: GeminiIcon },
{ prefix: 'grok-', icon: xAIIcon, size: 'h-[17px] w-[17px]' },
{ prefix: 'mistral-', icon: MistralIcon },
]
function getModelIconEntry(modelValue: string) {
const lower = modelValue.toLowerCase()
return MODEL_PROVIDER_ICONS.find((m) => lower.startsWith(m.prefix)) ?? null
}
/**
* Data shape for preview block nodes
*/
interface PreviewBlockData {
name: string
blockType: string
bgColor: string
rows: Array<{ title: string; value: string }>
tools?: PreviewTool[]
markdown?: string
hideTargetHandle?: boolean
hideSourceHandle?: boolean
index?: number
animate?: boolean
}
/**
* Handle styling matching the real WorkflowBlock handles.
* --workflow-edge in dark mode: #454545
*/
const HANDLE_BASE = '!z-[10] !border-none !bg-[#454545]'
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
/**
* Static preview block node matching the real WorkflowBlock styling.
* Renders a block header with icon + name, sub-block rows, and tool chips.
*
* Colors sourced from dark theme CSS variables:
* --surface-2: #232323, --border-1: #3d3d3d
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3
*/
export const PreviewBlockNode = memo(function PreviewBlockNode({
data,
}: NodeProps<PreviewBlockData>) {
const {
name,
blockType,
bgColor,
rows,
tools,
markdown,
hideTargetHandle,
hideSourceHandle,
index = 0,
animate = false,
} = data
const Icon = BLOCK_ICONS[blockType]
const delay = animate ? index * BLOCK_STAGGER : 0
if (blockType === 'note' && markdown) {
return (
<motion.div
className='relative'
initial={animate ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
>
<div className='w-[280px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
<div className='border-[#3d3d3d] border-b p-[8px]'>
<span className='font-medium text-[#e6e6e6] text-[16px]'>Note</span>
</div>
<div className='p-[10px]'>
<NoteMarkdown content={markdown} />
</div>
</div>
</motion.div>
)
}
const hasContent = rows.length > 0 || (tools && tools.length > 0)
return (
<motion.div
className='relative'
initial={animate ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
>
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
{/* Target handle (left side) */}
{!hideTargetHandle && (
<Handle
type='target'
position={Position.Left}
id='target'
className={HANDLE_LEFT}
style={{ top: '20px', transform: 'translateY(-50%)' }}
isConnectableStart={false}
isConnectableEnd={false}
/>
)}
{/* Header */}
<div
className={`flex items-center justify-between p-[8px] ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ background: bgColor }}
>
{Icon && <Icon className='h-[16px] w-[16px] text-white' />}
</div>
<span className='truncate font-medium text-[#e6e6e6] text-[16px]'>{name}</span>
</div>
</div>
{/* Sub-block rows + tools */}
{hasContent && (
<div className='flex flex-col gap-[8px] p-[8px]'>
{rows.map((row) => {
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
const ModelIcon = modelEntry?.icon
return (
<div key={row.title} className='flex items-center gap-[8px]'>
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px] capitalize'>
{row.title}
</span>
{row.value && (
<span className='flex min-w-0 flex-1 items-center justify-end gap-[5px] font-normal text-[#e6e6e6] text-[14px]'>
{ModelIcon && (
<ModelIcon
className={`inline-block flex-shrink-0 text-[#e6e6e6] ${modelEntry.size ?? 'h-[14px] w-[14px]'}`}
/>
)}
<span className='truncate'>{row.value}</span>
</span>
)}
</div>
)
})}
{/* Tool chips — inline with label */}
{tools && tools.length > 0 && (
<div className='flex items-center gap-[8px]'>
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
{tools.map((tool) => {
const ToolIcon = BLOCK_ICONS[tool.type]
return (
<div
key={tool.type}
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
>
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: tool.bgColor }}
>
{ToolIcon && <ToolIcon className='h-[10px] w-[10px] text-white' />}
</div>
<span className='font-normal text-[#e6e6e6] text-[12px]'>{tool.name}</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Source handle (right side) */}
{!hideSourceHandle && (
<Handle
type='source'
position={Position.Right}
id='source'
className={HANDLE_RIGHT}
style={{ top: '20px', transform: 'translateY(-50%)' }}
isConnectableStart={false}
isConnectableEnd={false}
/>
)}
</div>
</motion.div>
)
})
/**
* Renders lightweight markdown-like content for note blocks.
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
*/
function NoteMarkdown({ content }: { content: string }) {
const lines = content.split('\n')
return (
<div className='flex flex-col gap-[4px]'>
{lines.map((line, i) => {
const trimmed = line.trim()
if (!trimmed) return <div key={i} className='h-[4px]' />
if (trimmed === '---') {
return <hr key={i} className='my-[4px] border-[#3d3d3d] border-t' />
}
if (trimmed.startsWith('### ')) {
return (
<p key={i} className='font-semibold text-[#e6e6e6] text-[16px] leading-[1.3]'>
{trimmed.slice(4)}
</p>
)
}
return (
<p
key={i}
className='font-medium text-[#e6e6e6] text-[13px] leading-[1.5]'
dangerouslySetInnerHTML={{
__html: trimmed
.replace(/\*\*_(.+?)_\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/_"(.+?)"_/g, '<em>&ldquo;$1&rdquo;</em>')
.replace(/_(.+?)_/g, '<em>$1</em>'),
}}
/>
)
})}
</div>
)
}

View File

@@ -0,0 +1,228 @@
import type { Edge, Node } from 'reactflow'
import { Position } from 'reactflow'
/**
* Tool entry displayed as a chip on agent blocks
*/
export interface PreviewTool {
name: string
type: string
bgColor: string
}
/**
* Static block definition for preview workflow nodes
*/
export interface PreviewBlock {
id: string
name: string
type: string
bgColor: string
rows: Array<{ title: string; value: string }>
tools?: PreviewTool[]
markdown?: string
position: { x: number; y: number }
hideTargetHandle?: boolean
hideSourceHandle?: boolean
}
/**
* Workflow definition containing nodes, edges, and metadata
*/
export interface PreviewWorkflow {
id: string
name: string
color: string
blocks: PreviewBlock[]
edges: Array<{ id: string; source: string; target: string }>
/** Public JSON export used to seed the landing-page import flow */
seedPath?: string
}
/**
* IT Service Management workflow — Slack Trigger -> Agent (KB tool) -> Jira
*/
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
id: 'wf-it-service',
name: 'IT Service Management',
color: '#FF6B2C',
blocks: [
{
id: 'slack-1',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#it-support' },
{ title: 'Event', value: 'New Message' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-1',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'claude-sonnet-4.6' },
{ title: 'System Prompt', value: 'Triage incoming IT...' },
],
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#10B981' }],
position: { x: 420, y: 40 },
},
{
id: 'jira-1',
name: 'Jira',
type: 'jira',
bgColor: '#E0E0E0',
rows: [
{ title: 'Operation', value: 'Get Issues' },
{ title: 'Project', value: 'IT-Support' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'slack-1', target: 'agent-1' },
{ id: 'e-2', source: 'slack-1', target: 'jira-1' },
],
}
/**
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
*/
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
id: 'wf-content-pipeline',
name: 'Content Pipeline',
color: '#33C482',
blocks: [
{
id: 'schedule-1',
name: 'Schedule',
type: 'schedule',
bgColor: '#6366F1',
rows: [
{ title: 'Run Frequency', value: 'Daily' },
{ title: 'Time', value: '09:00 AM' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-2',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'grok-4' },
{ title: 'System Prompt', value: 'Repurpose trending...' },
],
tools: [
{ name: 'X', type: 'x', bgColor: '#000000' },
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
],
position: { x: 420, y: 180 },
hideSourceHandle: true,
},
],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
}
/**
* Empty "New Agent" workflow — a single note prompting the user to start building
*/
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
id: 'wf-new-agent',
name: 'New Agent',
color: '#787878',
blocks: [
{
id: 'note-1',
name: '',
type: 'note',
bgColor: 'transparent',
rows: [],
markdown: '### What will you build?\n\n_"Find Linear todos and send in Slack"_',
position: { x: 0, y: 0 },
hideTargetHandle: true,
hideSourceHandle: true,
},
],
edges: [],
}
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
CONTENT_PIPELINE_WORKFLOW,
IT_SERVICE_WORKFLOW,
NEW_AGENT_WORKFLOW,
]
/** Stagger delay between each block appearing (seconds). */
export const BLOCK_STAGGER = 0.12
/** Shared cubic-bezier easing — fast deceleration, gentle settle. */
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
/** Shared edge style applied to all preview workflow connections */
const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
/**
* Converts a PreviewWorkflow to React Flow nodes and edges.
*
* @param workflow - The workflow definition
* @param animate - When true, node/edge data includes animation metadata
*/
export function toReactFlowElements(
workflow: PreviewWorkflow,
animate = false
): {
nodes: Node[]
edges: Edge[]
} {
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
const nodes: Node[] = workflow.blocks.map((block, index) => ({
id: block.id,
type: 'previewBlock',
position: block.position,
data: {
name: block.name,
blockType: block.type,
bgColor: block.bgColor,
rows: block.rows,
tools: block.tools,
markdown: block.markdown,
hideTargetHandle: block.hideTargetHandle,
hideSourceHandle: block.hideSourceHandle,
index,
animate,
},
draggable: true,
selectable: false,
connectable: false,
sourcePosition: Position.Right,
targetPosition: Position.Left,
}))
const edges: Edge[] = workflow.edges.map((e) => {
const sourceIndex = blockIndexMap.get(e.source) ?? 0
return {
id: e.id,
source: e.source,
target: e.target,
type: 'previewEdge',
animated: false,
style: EDGE_STYLE,
sourceHandle: 'source',
targetHandle: 'target',
data: {
animate,
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
},
}
})
return { nodes, edges }
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
import {
EASE_OUT,
PREVIEW_WORKFLOWS,
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
const containerVariants: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.15 },
},
}
const sidebarVariants: Variants = {
hidden: { opacity: 0, x: -12 },
visible: {
opacity: 1,
x: 0,
transition: {
x: { duration: 0.25, ease: EASE_OUT },
opacity: { duration: 0.25, ease: EASE_OUT },
},
},
}
const panelVariants: Variants = {
hidden: { opacity: 0, x: 12 },
visible: {
opacity: 1,
x: 0,
transition: {
x: { duration: 0.25, ease: EASE_OUT },
opacity: { duration: 0.25, ease: EASE_OUT },
},
},
}
/**
* Interactive workspace preview for the hero section.
*
* Renders a lightweight replica of the Sim workspace with:
* - A sidebar with two selectable workflows
* - A ReactFlow canvas showing the active workflow's blocks and edges
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
*
* Everything except the workflow items and the copilot input is non-interactive.
* On mount the sidebar slides from left and the panel from right. The canvas
* background stays fully opaque; individual block nodes animate in with a
* staggered fade. Edges draw left-to-right. Animations only fire on initial
* load — workflow switches render instantly.
*/
export function LandingPreview() {
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
const isInitialMount = useRef(true)
useEffect(() => {
isInitialMount.current = false
}, [])
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
return (
<motion.div
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
initial='hidden'
animate='visible'
variants={containerVariants}
>
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
<LandingPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
onSelectWorkflow={setActiveWorkflowId}
/>
</motion.div>
<div className='relative flex-1 overflow-hidden'>
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
</div>
<motion.div className='hidden lg:flex' variants={panelVariants}>
<LandingPreviewPanel />
</motion.div>
</motion.div>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { GithubOutlineIcon } from '@/components/icons'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
const logger = createLogger('github-stars')
const INITIAL_STARS = '26.4k'
/**
* Client component that displays GitHub stars count.
*
* Isolated as a client component to allow the parent Navbar to remain
* a Server Component for optimal SEO/GEO crawlability.
*/
export function GitHubStars() {
const [stars, setStars] = useState(INITIAL_STARS)
useEffect(() => {
getFormattedGitHubStars()
.then(setStars)
.catch((error) => {
logger.warn('Failed to fetch GitHub stars', error)
})
}, [])
return (
<a
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-[8px] px-[12px]'
aria-label={`GitHub repository — ${stars} stars`}
>
<GithubOutlineIcon className='h-[14px] w-[14px]' />
<span aria-live='polite'>{stars}</span>
</a>
)
}

View File

@@ -0,0 +1,97 @@
import Image from 'next/image'
import Link from 'next/link'
import { ChevronDown } from '@/components/emcn'
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
interface NavLink {
label: string
href: string
external?: boolean
icon?: 'chevron'
}
const NAV_LINKS: NavLink[] = [
{ label: 'Docs', href: '/docs', icon: 'chevron' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' },
]
/** Logo and nav edge: horizontal padding (px) for left/right symmetry. */
const LOGO_CELL = 'flex items-center px-[20px]'
/** Links: even spacing between items. */
const LINK_CELL = 'flex items-center px-[14px]'
export default function Navbar() {
return (
<nav
aria-label='Primary navigation'
className='flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
itemScope
itemType='https://schema.org/SiteNavigationElement'
>
{/* Logo */}
<Link href='/' className={LOGO_CELL} aria-label='Sim home' itemProp='url'>
<span itemProp='name' className='sr-only'>
Sim
</span>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto'
priority
/>
</Link>
{/* Links */}
<ul className='mt-[0.75px] flex'>
{NAV_LINKS.map(({ label, href, external, icon }) => (
<li key={label} className='flex'>
{external ? (
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
{label}
</a>
) : (
<Link
href={href}
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
aria-label={label}
>
{label}
{icon === 'chevron' && (
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
)}
</Link>
)}
</li>
))}
<li className='flex'>
<GitHubStars />
</li>
</ul>
<div className='flex-1' />
{/* CTAs */}
<div className='flex items-center gap-[8px] px-[20px]'>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
</nav>
)
}

View File

@@ -0,0 +1,204 @@
import Link from 'next/link'
import { Badge } from '@/components/emcn'
interface PricingTier {
id: string
name: string
description: string
price: string
billingPeriod?: string
color: string
features: string[]
cta: { label: string; href: string }
}
const PRICING_TIERS: PricingTier[] = [
{
id: 'community',
name: 'Community',
description: 'For individuals getting started with AI agents',
price: 'Free',
color: '#2ABBF8',
features: [
'3,000 credits/mo',
'5GB file storage',
'5 min execution limit',
'Limited log retention',
'CLI/SDK Access',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'pro',
name: 'Pro',
description: 'For professionals building production workflows',
price: '$25',
billingPeriod: 'per month',
color: '#00F701',
features: [
'6,000 credits/mo',
'+50 daily refresh credits',
'150 runs/min (sync)',
'50 min sync execution limit',
'50GB file storage',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'max',
name: 'Max',
description: 'For power users and teams building at scale',
price: '$100',
billingPeriod: 'per month',
color: '#FA4EDF',
features: [
'25,000 credits/mo',
'+200 daily refresh credits',
'300 runs/min (sync)',
'50 min sync execution limit',
'500GB file storage',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'For organizations needing security and scale',
price: 'Custom',
color: '#FFCC02',
features: ['Custom infra limits', 'SSO', 'SOC2', 'Self hosting', 'Dedicated support'],
cta: { label: 'Book a demo', href: '/contact' },
},
]
function CheckIcon({ color }: { color: string }) {
return (
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
<path
d='M2.5 7L5.5 10L11.5 4'
stroke={color}
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
interface PricingCardProps {
tier: PricingTier
}
function PricingCard({ tier }: PricingCardProps) {
const isEnterprise = tier.id === 'enterprise'
const isPro = tier.id === 'pro'
return (
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[#E5E5E5] border-b-0 bg-white p-5'>
<div className='flex flex-col'>
<h3
id={`${tier.id}-heading`}
className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[100%] tracking-[-0.02em]'
>
{tier.name}
</h3>
<p className='mt-2 min-h-[44px] font-[430] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
{tier.description}
</p>
<p className='mt-4 flex items-center gap-1.5 font-[430] font-season text-[#1C1C1C] text-[20px] leading-[100%] tracking-[-0.02em]'>
{tier.price}
{tier.billingPeriod && (
<span className='text-[#737373] text-[16px]'>{tier.billingPeriod}</span>
)}
</p>
<div className='mt-4'>
{isEnterprise ? (
<a
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</a>
) : isPro ? (
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
>
{tier.cta.label}
</Link>
) : (
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</Link>
)}
</div>
</div>
<ul className='flex flex-col gap-2'>
{tier.features.map((feature) => (
<li key={feature} className='flex items-center gap-2'>
<CheckIcon color='#404040' />
<span className='font-[400] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
{feature}
</span>
</li>
))}
</ul>
</div>
<div className='relative h-[6px]'>
<div
className='absolute inset-0 rounded-b-sm opacity-60'
style={{ backgroundColor: tier.color }}
/>
<div
className='absolute top-0 right-0 bottom-0 left-[12%] rounded-b-sm opacity-60'
style={{ backgroundColor: tier.color }}
/>
<div
className='absolute top-0 right-0 bottom-0 left-[25%] rounded-b-sm'
style={{ backgroundColor: tier.color }}
/>
</div>
</article>
)
}
/**
* Pricing section -- tiered pricing plans with feature comparison.
*/
export default function Pricing() {
return (
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
<Badge
variant='blue'
size='md'
dot
className='bg-[#2ABBF8]/10 font-season text-[#2ABBF8] uppercase tracking-[0.02em]'
>
Pricing
</Badge>
<h2
id='pricing-heading'
className='font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
>
Pricing
</h2>
</div>
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
{PRICING_TIERS.map((tier) => (
<PricingCard key={tier.id} tier={tier} />
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,224 @@
/**
* JSON-LD structured data for the landing page.
*
* Renders a `<script type="application/ld+json">` with Schema.org markup.
* Single source of truth for machine-readable page metadata.
*
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, FAQPage.
*
* AI crawler behavior (2025-2026):
* - Google AI Overviews / Bing Copilot parse JSON-LD from their search indexes.
* - GPTBot indexes JSON-LD during crawling (92% of LLM crawlers parse JSON-LD first).
* - Perplexity / Claude prioritize visible HTML over JSON-LD during direct fetch.
* - All claims here must also appear as visible text on the page.
*
* Maintenance:
* - Offer prices must match the Pricing component exactly.
* - `sameAs` links must match the Footer social links.
* - Do not add `aggregateRating` without real, verifiable review data.
*/
export default function StructuredData() {
const structuredData = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
'@id': 'https://sim.ai/#organization',
name: 'Sim',
alternateName: 'Sim Studio',
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
url: 'https://sim.ai',
logo: {
'@type': 'ImageObject',
'@id': 'https://sim.ai/#logo',
url: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
contentUrl: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
width: 49.78314,
height: 24.276,
caption: 'Sim Logo',
},
image: { '@id': 'https://sim.ai/#logo' },
sameAs: [
'https://x.com/simdotai',
'https://github.com/simstudioai/sim',
'https://www.linkedin.com/company/simstudioai/',
'https://discord.gg/Hr4UWYEcTT',
],
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',
availableLanguage: ['en'],
},
},
{
'@type': 'WebSite',
'@id': 'https://sim.ai/#website',
url: 'https://sim.ai',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
publisher: { '@id': 'https://sim.ai/#organization' },
inLanguage: 'en-US',
},
{
'@type': 'WebPage',
'@id': 'https://sim.ai/#webpage',
url: 'https://sim.ai',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
isPartOf: { '@id': 'https://sim.ai/#website' },
about: { '@id': 'https://sim.ai/#software' },
datePublished: '2024-01-01T00:00:00+00:00',
dateModified: new Date().toISOString(),
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
breadcrumb: { '@id': 'https://sim.ai/#breadcrumb' },
inLanguage: 'en-US',
potentialAction: [{ '@type': 'ReadAction', target: ['https://sim.ai'] }],
},
{
'@type': 'BreadcrumbList',
'@id': 'https://sim.ai/#breadcrumb',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
],
},
{
'@type': 'WebApplication',
'@id': 'https://sim.ai/#software',
url: 'https://sim.ai',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
browserRequirements: 'Requires a modern browser with JavaScript enabled',
offers: [
{
'@type': 'Offer',
name: 'Community Plan — 3,000 credits included',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Pro Plan — 6,000 credits/month',
price: '25',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '25',
priceCurrency: 'USD',
unitText: 'MONTH',
billingIncrement: 1,
},
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Max Plan — 25,000 credits/month',
price: '100',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '100',
priceCurrency: 'USD',
unitText: 'MONTH',
billingIncrement: 1,
},
availability: 'https://schema.org/InStock',
},
],
featureList: [
'AI agent creation',
'Agentic workflow orchestration',
'1,000+ integrations',
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Knowledge base creation',
'Table creation',
'Document creation',
'API access',
'Custom functions',
'Scheduled workflows',
'Event triggers',
],
review: [
{
'@type': 'Review',
author: { '@type': 'Person', name: 'Hasan Toor' },
reviewBody:
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
url: 'https://x.com/hasantoxr/status/1912909502036525271',
},
{
'@type': 'Review',
author: { '@type': 'Person', name: 'nizzy' },
reviewBody:
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
url: 'https://x.com/nizzyabi/status/1907864421227180368',
},
{
'@type': 'Review',
author: { '@type': 'Organization', name: 'xyflow' },
reviewBody: 'A very good looking agent workflow builder and open source!',
url: 'https://x.com/xyflowdev/status/1909501499719438670',
},
],
},
{
'@type': 'FAQPage',
'@id': 'https://sim.ai/#faq',
mainEntity: [
{
'@type': 'Question',
name: 'What is Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
},
},
{
'@type': 'Question',
name: 'Which AI models does Sim support?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim supports all major AI models including OpenAI (GPT-5, GPT-4o), Anthropic (Claude), Google (Gemini), xAI (Grok), Mistral, Perplexity, and many more. You can also connect to open-source models via Ollama.',
},
},
{
'@type': 'Question',
name: 'How much does Sim cost?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim offers a free Community plan with 3,000 credits, a Pro plan at $25/month with 6,000 credits, a Max plan at $100/month with 25,000 credits, team plans available for both tiers, and custom Enterprise pricing. All plans include CLI/SDK access.',
},
},
{
'@type': 'Question',
name: 'Do I need coding skills to use Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
},
},
{
'@type': 'Question',
name: 'What enterprise features does Sim offer?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
},
},
],
},
],
}
return (
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
)
}

View File

@@ -0,0 +1,595 @@
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/**
* OCR Invoice to DB — Start → Agent (Textract) → Supabase
* Pattern: Straight line (all blocks aligned at top)
*/
const OCR_INVOICE_WORKFLOW: PreviewWorkflow = {
id: 'tpl-ocr-invoice',
name: 'OCR Invoice to DB',
color: '#2ABBF8',
seedPath: '/landing-page-templates/ocr-invoice-db-d502887e-8750-40a3-a98b-fb2a4e725a4d.json',
blocks: [
{
id: 'starter-1',
name: 'Start',
type: 'starter',
bgColor: '#34B5FF',
rows: [{ title: 'URL', value: 'invoice.pdf' }],
position: { x: 40, y: 80 },
hideTargetHandle: true,
},
{
id: 'agent-1',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gpt-5.2' },
{ title: 'System Prompt', value: 'Extract invoice fields...' },
],
tools: [{ name: 'Textract', type: 'textract', bgColor: '#055F4E' }],
position: { x: 400, y: 100 },
},
{
id: 'supabase-1',
name: 'Supabase',
type: 'supabase',
bgColor: '#1C1C1C',
rows: [
{ title: 'Table', value: 'invoices' },
{ title: 'Operation', value: 'Insert Row' },
],
position: { x: 760, y: 80 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'starter-1', target: 'agent-1' },
{ id: 'e-2', source: 'agent-1', target: 'supabase-1' },
],
}
/**
* GitHub Release Agent — GitHub → Agent → Slack
* Pattern: Convex (low → high → low)
*/
const GITHUB_RELEASE_WORKFLOW: PreviewWorkflow = {
id: 'tpl-github-release',
name: 'GitHub Release Agent',
color: '#00F701',
seedPath: '/landing-page-templates/gh-release-agent-d3bed10e-fc87-4fdb-b458-80d8e43757d3.json',
blocks: [
{
id: 'github-1',
name: 'GitHub',
type: 'github',
bgColor: '#181C1E',
rows: [
{ title: 'Event', value: 'New Release' },
{ title: 'Repository', value: 'org/repo' },
],
position: { x: 60, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-2',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'claude-sonnet-4.6' },
{ title: 'System Prompt', value: 'Summarize changelog...' },
],
position: { x: 370, y: 50 },
},
{
id: 'slack-1',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#releases' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 680, y: 140 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'github-1', target: 'agent-2' },
{ id: 'e-2', source: 'agent-2', target: 'slack-1' },
],
}
/**
* Meeting Follow-up Agent — Google Calendar → Agent → Gmail
* Pattern: Concave (high → low → high)
*/
const MEETING_FOLLOWUP_WORKFLOW: PreviewWorkflow = {
id: 'tpl-meeting-followup',
name: 'Meeting Follow-up Agent',
color: '#FFCC02',
seedPath:
'/landing-page-templates/meeting-followup-agent-a2357a2f-67f7-40c1-8e64-301bcd604239.json',
blocks: [
{
id: 'gcal-1',
name: 'Google Calendar',
type: 'google_calendar',
bgColor: '#E0E0E0',
rows: [
{ title: 'Event', value: 'Meeting Ended' },
{ title: 'Calendar', value: 'Work' },
],
position: { x: 60, y: 60 },
hideTargetHandle: true,
},
{
id: 'agent-3',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gemini-2.5-pro' },
{ title: 'System Prompt', value: 'Draft follow-up email...' },
],
position: { x: 370, y: 150 },
},
{
id: 'gmail-1',
name: 'Gmail',
type: 'gmail',
bgColor: '#E0E0E0',
rows: [
{ title: 'Operation', value: 'Send Email' },
{ title: 'To', value: 'attendees' },
],
position: { x: 680, y: 60 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'gcal-1', target: 'agent-3' },
{ id: 'e-2', source: 'agent-3', target: 'gmail-1' },
],
}
/**
* CV/Resume Scanner — Start → Agent (Reducto) → Google Sheets
* Pattern: Convex (low → high → low)
*/
const CV_SCANNER_WORKFLOW: PreviewWorkflow = {
id: 'tpl-cv-scanner',
name: 'CV/Resume Scanner',
color: '#FA4EDF',
seedPath: '/landing-page-templates/resume-parser-d083c931-8788-4c6e-814c-0788830e164d.json',
blocks: [
{
id: 'starter-2',
name: 'Start',
type: 'starter',
bgColor: '#34B5FF',
rows: [{ title: 'File URL', value: 'resume.pdf' }],
position: { x: 60, y: 145 },
hideTargetHandle: true,
},
{
id: 'agent-4',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'claude-opus-4.6' },
{ title: 'System Prompt', value: 'Parse resume fields...' },
],
tools: [{ name: 'Reducto', type: 'reducto', bgColor: '#5c0c5c' }],
position: { x: 370, y: 55 },
},
{
id: 'gsheets-1',
name: 'Google Sheets',
type: 'google_sheets',
bgColor: '#E0E0E0',
rows: [
{ title: 'Spreadsheet', value: 'Candidates' },
{ title: 'Operation', value: 'Append Row' },
],
position: { x: 680, y: 145 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'starter-2', target: 'agent-4' },
{ id: 'e-2', source: 'agent-4', target: 'gsheets-1' },
],
}
/**
* Email Triage Agent — Gmail → Agent (KB) → fan-out to Slack + Linear
* Pattern: Fan-out (input low → agent mid → outputs spread vertically)
*/
const EMAIL_TRIAGE_WORKFLOW: PreviewWorkflow = {
id: 'tpl-email-triage',
name: 'Email Triage Agent',
color: '#FF6B2C',
seedPath: '/landing-page-templates/email-triage-57e84f83-c583-4e74-b73a-080d25f2074d.json',
blocks: [
{
id: 'gmail-2',
name: 'Gmail',
type: 'gmail',
bgColor: '#E0E0E0',
rows: [
{ title: 'Event', value: 'New Email' },
{ title: 'Label', value: 'Inbox' },
],
position: { x: 60, y: 130 },
hideTargetHandle: true,
},
{
id: 'agent-5',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gpt-5.2-mini' },
{ title: 'System Prompt', value: 'Classify and route...' },
],
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
position: { x: 370, y: 100 },
},
{
id: 'slack-2',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#urgent' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 680, y: 20 },
hideSourceHandle: true,
},
{
id: 'linear-1',
name: 'Linear',
type: 'linear',
bgColor: '#5E6AD2',
rows: [
{ title: 'Project', value: 'Support' },
{ title: 'Operation', value: 'Create Issue' },
],
position: { x: 680, y: 200 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'gmail-2', target: 'agent-5' },
{ id: 'e-2', source: 'agent-5', target: 'slack-2' },
{ id: 'e-3', source: 'agent-5', target: 'linear-1' },
],
}
/**
* Competitor Monitor — Schedule → Agent (Firecrawl) → Slack
* Pattern: Concave (high → low → high)
*/
const COMPETITOR_MONITOR_WORKFLOW: PreviewWorkflow = {
id: 'tpl-competitor-monitor',
name: 'Competitor Monitor',
color: '#6366F1',
seedPath: '/landing-page-templates/competitor-monitor-52454688-49ae-4279-894a-aa6494f10e3a.json',
blocks: [
{
id: 'schedule-1',
name: 'Schedule',
type: 'schedule',
bgColor: '#6366F1',
rows: [
{ title: 'Run Frequency', value: 'Daily' },
{ title: 'Time', value: '08:00 AM' },
],
position: { x: 60, y: 50 },
hideTargetHandle: true,
},
{
id: 'agent-6',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'grok-4' },
{ title: 'System Prompt', value: 'Monitor competitor...' },
],
tools: [{ name: 'Firecrawl', type: 'firecrawl', bgColor: '#181C1E' }],
position: { x: 370, y: 150 },
},
{
id: 'slack-3',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#competitive-intel' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 680, y: 50 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'schedule-1', target: 'agent-6' },
{ id: 'e-2', source: 'agent-6', target: 'slack-3' },
],
}
/**
* Social Listening Agent — Schedule → Agent (Reddit + X) → Notion
* Pattern: Convex (low → high → low)
*/
const SOCIAL_LISTENING_WORKFLOW: PreviewWorkflow = {
id: 'tpl-social-listening',
name: 'Social Listening Agent',
color: '#F43F5E',
seedPath: '/landing-page-templates/brand-mention-d2578496-d153-4db1-8ef1-c738cfa94a96.json',
blocks: [
{
id: 'schedule-2',
name: 'Schedule',
type: 'schedule',
bgColor: '#6366F1',
rows: [{ title: 'Run Frequency', value: 'Hourly' }],
position: { x: 60, y: 150 },
hideTargetHandle: true,
},
{
id: 'agent-7',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gemini-2.5-flash' },
{ title: 'System Prompt', value: 'Track brand mentions...' },
],
tools: [
{ name: 'Reddit', type: 'reddit', bgColor: '#FF5700' },
{ name: 'X', type: 'x', bgColor: '#000000' },
],
position: { x: 370, y: 55 },
},
{
id: 'notion-1',
name: 'Notion',
type: 'notion',
bgColor: '#181C1E',
rows: [
{ title: 'Database', value: 'Brand Mentions' },
{ title: 'Operation', value: 'Create Page' },
],
position: { x: 680, y: 150 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'schedule-2', target: 'agent-7' },
{ id: 'e-2', source: 'agent-7', target: 'notion-1' },
],
}
/**
* Data Enrichment Pipeline — Start → Agent (LinkedIn) → Google Sheets
* Pattern: Concave (high → low → high)
*/
const DATA_ENRICHMENT_WORKFLOW: PreviewWorkflow = {
id: 'tpl-data-enrichment',
name: 'Data Enrichment Pipeline',
color: '#14B8A6',
seedPath: '/landing-page-templates/lead-enricher-6ed8dede-1df6-4962-95f4-887abf524b38.json',
blocks: [
{
id: 'starter-3',
name: 'Start',
type: 'starter',
bgColor: '#34B5FF',
rows: [{ title: 'Email', value: 'lead@company.com' }],
position: { x: 60, y: 55 },
hideTargetHandle: true,
},
{
id: 'agent-8',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'mistral-large' },
{ title: 'System Prompt', value: 'Enrich lead data...' },
],
tools: [{ name: 'LinkedIn', type: 'linkedin', bgColor: '#0072B1' }],
position: { x: 370, y: 145 },
},
{
id: 'gsheets-2',
name: 'Google Sheets',
type: 'google_sheets',
bgColor: '#E0E0E0',
rows: [
{ title: 'Spreadsheet', value: 'Lead Database' },
{ title: 'Operation', value: 'Update Row' },
],
position: { x: 680, y: 55 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'starter-3', target: 'agent-8' },
{ id: 'e-2', source: 'agent-8', target: 'gsheets-2' },
],
}
/**
* Customer Feedback Digest — Schedule → Agent → Slack
* Pattern: Convex (low → high → low)
*/
const FEEDBACK_DIGEST_WORKFLOW: PreviewWorkflow = {
id: 'tpl-feedback-digest',
name: 'Customer Feedback Digest',
color: '#F59E0B',
seedPath:
'/landing-page-templates/customer-feedback-digest-2a1c59de-fdcc-4ae0-b448-69a58b36c29a.json',
blocks: [
{
id: 'schedule-3',
name: 'Schedule',
type: 'schedule',
bgColor: '#6366F1',
rows: [
{ title: 'Run Frequency', value: 'Daily' },
{ title: 'Time', value: '09:00 AM' },
],
position: { x: 60, y: 145 },
hideTargetHandle: true,
},
{
id: 'agent-9',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'claude-sonnet-4.6' },
{ title: 'System Prompt', value: 'Analyze customer feedback...' },
],
tools: [{ name: 'Airtable', type: 'airtable', bgColor: '#18BFFF' }],
position: { x: 370, y: 50 },
},
{
id: 'slack-4',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#product-feedback' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 680, y: 145 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'schedule-3', target: 'agent-9' },
{ id: 'e-2', source: 'agent-9', target: 'slack-4' },
],
}
/**
* PR Review Agent — GitHub → Agent → Slack
* Pattern: Concave (high → low → high)
*/
const PR_REVIEW_WORKFLOW: PreviewWorkflow = {
id: 'tpl-pr-review',
name: 'PR Review Agent',
color: '#06B6D4',
seedPath: '/landing-page-templates/pr-review-cb5f2d92-a324-4958-8303-4710c572b71d.json',
blocks: [
{
id: 'github-2',
name: 'GitHub',
type: 'github',
bgColor: '#181C1E',
rows: [
{ title: 'Event', value: 'Pull Request Opened' },
{ title: 'Repository', value: 'org/repo' },
],
position: { x: 60, y: 60 },
hideTargetHandle: true,
},
{
id: 'agent-10',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gpt-5.2' },
{ title: 'System Prompt', value: 'Review code changes...' },
],
position: { x: 370, y: 155 },
},
{
id: 'slack-5',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#code-reviews' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 680, y: 60 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'github-2', target: 'agent-10' },
{ id: 'e-2', source: 'agent-10', target: 'slack-5' },
],
}
/**
* Knowledge Base QA — Start → Agent (KB) → Response
* Pattern: Convex (low → high → low)
*/
const KNOWLEDGE_QA_WORKFLOW: PreviewWorkflow = {
id: 'tpl-knowledge-qa',
name: 'Knowledge Base QA',
color: '#84CC16',
seedPath: '/landing-page-templates/knowledge-base-qa-e9dcd9f1-18bd-4163-b5d8-3e239a297526.json',
blocks: [
{
id: 'starter-4',
name: 'Start',
type: 'starter',
bgColor: '#34B5FF',
rows: [{ title: 'Question', value: 'How do I...' }],
position: { x: 60, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-11',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gemini-2.5-pro' },
{ title: 'System Prompt', value: 'Answer using knowledge...' },
],
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
position: { x: 370, y: 50 },
},
{
id: 'starter-5',
name: 'Response',
type: 'starter',
bgColor: '#34B5FF',
rows: [{ title: 'Answer', value: 'Based on your docs...' }],
position: { x: 680, y: 140 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'starter-4', target: 'agent-11' },
{ id: 'e-2', source: 'agent-11', target: 'starter-5' },
],
}
export const TEMPLATE_WORKFLOWS: PreviewWorkflow[] = [
OCR_INVOICE_WORKFLOW,
GITHUB_RELEASE_WORKFLOW,
MEETING_FOLLOWUP_WORKFLOW,
CV_SCANNER_WORKFLOW,
EMAIL_TRIAGE_WORKFLOW,
COMPETITOR_MONITOR_WORKFLOW,
SOCIAL_LISTENING_WORKFLOW,
DATA_ENRICHMENT_WORKFLOW,
FEEDBACK_DIGEST_WORKFLOW,
PR_REVIEW_WORKFLOW,
KNOWLEDGE_QA_WORKFLOW,
]

View File

@@ -0,0 +1,596 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Badge, ChevronDown } from '@/components/emcn'
import { LandingWorkflowSeedStorage } from '@/lib/core/utils/browser-storage'
import { cn } from '@/lib/core/utils/cn'
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
const logger = createLogger('LandingTemplates')
const LandingPreviewWorkflow = dynamic(
() =>
import(
'@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
).then((mod) => mod.LandingPreviewWorkflow),
{
ssr: false,
loading: () => <div className='h-full w-full bg-[#1b1b1b]' />,
}
)
function hexToRgba(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16)
const g = Number.parseInt(hex.slice(3, 5), 16)
const b = Number.parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
const LEFT_WALL_CLIP = 'polygon(0 8px, 100% 0, 100% 100%, 0 100%)'
const BOTTOM_WALL_CLIP = 'polygon(0 0, 100% 0, calc(100% - 8px) 100%, 0 100%)'
interface DepthConfig {
color: string
segments: readonly (readonly [opacity: number, width: number])[]
}
/** Depth color and gradient segment pattern per template. Segments are `[opacity, width%]` tuples. */
const DEPTH_CONFIGS: Record<string, DepthConfig> = {
'tpl-ocr-invoice': {
color: '#2ABBF8',
segments: [
[0.3, 10],
[0.5, 8],
[0.8, 6],
[1, 5],
[0.4, 12],
[0.7, 8],
[1, 6],
[0.5, 10],
[0.9, 7],
[0.6, 12],
[1, 8],
[0.35, 8],
],
},
'tpl-github-release': {
color: '#00F701',
segments: [
[0.4, 8],
[0.7, 6],
[1, 5],
[0.5, 14],
[0.85, 8],
[0.3, 12],
[1, 6],
[0.6, 10],
[0.9, 7],
[0.45, 8],
[1, 8],
[0.7, 8],
],
},
'tpl-meeting-followup': {
color: '#FFCC02',
segments: [
[0.5, 12],
[0.8, 6],
[0.35, 10],
[1, 5],
[0.6, 8],
[0.9, 7],
[0.4, 14],
[1, 6],
[0.7, 10],
[0.5, 8],
[1, 6],
[0.3, 8],
],
},
'tpl-cv-scanner': {
color: '#FA4EDF',
segments: [
[0.35, 6],
[0.6, 10],
[0.9, 5],
[1, 6],
[0.4, 8],
[0.75, 12],
[0.5, 7],
[1, 5],
[0.3, 10],
[0.8, 8],
[0.6, 9],
[1, 6],
[0.45, 8],
],
},
'tpl-email-triage': {
color: '#FF6B2C',
segments: [
[0.4, 10],
[0.7, 8],
[1, 5],
[0.5, 12],
[0.85, 6],
[0.3, 10],
[1, 6],
[0.6, 8],
[0.9, 7],
[0.4, 12],
[1, 8],
[0.65, 8],
],
},
'tpl-competitor-monitor': {
color: '#6366F1',
segments: [
[0.3, 8],
[0.55, 10],
[0.8, 6],
[1, 5],
[0.4, 12],
[0.7, 7],
[0.9, 8],
[0.5, 10],
[1, 6],
[0.35, 8],
[0.75, 6],
[1, 6],
[0.6, 8],
],
},
'tpl-social-listening': {
color: '#F43F5E',
segments: [
[0.5, 10],
[0.8, 6],
[0.4, 8],
[1, 5],
[0.6, 12],
[0.35, 8],
[0.9, 7],
[1, 6],
[0.5, 10],
[0.75, 8],
[0.4, 6],
[1, 6],
[0.65, 8],
],
},
'tpl-data-enrichment': {
color: '#14B8A6',
segments: [
[0.35, 8],
[0.6, 6],
[0.9, 5],
[0.4, 12],
[1, 6],
[0.7, 10],
[0.5, 7],
[0.85, 8],
[1, 5],
[0.3, 10],
[0.65, 8],
[1, 7],
[0.5, 8],
],
},
'tpl-feedback-digest': {
color: '#F59E0B',
segments: [
[0.4, 10],
[0.65, 6],
[0.9, 5],
[0.5, 12],
[1, 6],
[0.35, 8],
[0.75, 7],
[1, 5],
[0.6, 10],
[0.85, 8],
[0.45, 6],
[1, 8],
[0.55, 9],
],
},
'tpl-pr-review': {
color: '#06B6D4',
segments: [
[0.35, 8],
[0.7, 7],
[1, 5],
[0.45, 10],
[0.8, 6],
[0.3, 12],
[1, 6],
[0.55, 8],
[0.9, 7],
[0.4, 10],
[1, 6],
[0.65, 8],
[0.5, 7],
],
},
'tpl-knowledge-qa': {
color: '#84CC16',
segments: [
[0.5, 8],
[0.75, 6],
[0.4, 10],
[1, 5],
[0.6, 8],
[0.85, 7],
[0.35, 12],
[1, 6],
[0.7, 8],
[0.45, 10],
[0.9, 6],
[1, 6],
[0.55, 8],
],
},
}
const SCROLL_BLOCK_RX = '2.59574'
/**
* Two-row horizontal block strip for the scroll-driven reveal in the templates section.
* Same structural pattern as the hero's top-right blocks with matching colours:
* blue (left) → pink (middle) → green (right).
*/
const SCROLL_BLOCK_RECTS = [
{ opacity: 0.6, x: '-34.24', y: '0', width: '34.24', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '-17.38', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '33.73', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.34', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.24', y: '0', width: '34.24', height: '33.73', fill: '#2ABBF8' },
{ opacity: 1, x: '34.24', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '51.62', y: '16.86', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '68.48', y: '0', width: '54.65', height: '16.86', fill: '#FA4EDF' },
{ opacity: 0.6, x: '106.27', y: '0', width: '34.24', height: '33.73', fill: '#FA4EDF' },
{ opacity: 0.6, x: '106.27', y: '0', width: '51.10', height: '16.86', fill: '#FA4EDF' },
{ opacity: 1, x: '123.65', y: '16.86', width: '16.86', height: '16.86', fill: '#FA4EDF' },
{ opacity: 0.6, x: '157.37', y: '0', width: '34.24', height: '16.86', fill: '#FA4EDF' },
{ opacity: 1, x: '157.37', y: '0', width: '16.86', height: '16.86', fill: '#FA4EDF' },
{ opacity: 0.6, x: '209.0', y: '0', width: '68.48', height: '16.86', fill: '#00F701' },
{ opacity: 0.6, x: '209.14', y: '0', width: '16.86', height: '33.73', fill: '#00F701' },
{ opacity: 0.6, x: '243.23', y: '0', width: '34.24', height: '33.73', fill: '#00F701' },
{ opacity: 1, x: '243.23', y: '0', width: '16.86', height: '16.86', fill: '#00F701' },
{ opacity: 0.6, x: '260.10', y: '0', width: '34.04', height: '16.86', fill: '#00F701' },
{ opacity: 1, x: '260.61', y: '16.86', width: '16.86', height: '16.86', fill: '#00F701' },
] as const
const SCROLL_BLOCK_MAX_X = Math.max(...SCROLL_BLOCK_RECTS.map((r) => Number.parseFloat(r.x)))
const SCROLL_REVEAL_START = 0.05
const SCROLL_REVEAL_SPAN = 0.7
const SCROLL_FADE_IN = 0.03
function getScrollBlockThreshold(x: string): number {
const normalized = Number.parseFloat(x) / SCROLL_BLOCK_MAX_X
return SCROLL_REVEAL_START + (1 - normalized) * SCROLL_REVEAL_SPAN
}
interface ScrollBlockRectProps {
scrollYProgress: MotionValue<number>
rect: (typeof SCROLL_BLOCK_RECTS)[number]
}
/** Renders a single SVG rect whose opacity is driven by scroll progress. */
function ScrollBlockRect({ scrollYProgress, rect }: ScrollBlockRectProps) {
const threshold = getScrollBlockThreshold(rect.x)
const opacity = useTransform(
scrollYProgress,
[threshold, threshold + SCROLL_FADE_IN],
[0, rect.opacity]
)
return (
<motion.rect
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
rx={SCROLL_BLOCK_RX}
fill={rect.fill}
style={{ opacity }}
/>
)
}
function buildBottomWallStyle(config: DepthConfig) {
let pos = 0
const stops: string[] = []
for (const [opacity, width] of config.segments) {
const c = hexToRgba(config.color, opacity)
stops.push(`${c} ${pos}%`, `${c} ${pos + width}%`)
pos += width
}
return {
clipPath: BOTTOM_WALL_CLIP,
background: `linear-gradient(135deg, ${stops.join(', ')})`,
}
}
interface DotGridProps {
className?: string
cols: number
rows: number
gap?: number
}
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
return (
<div
aria-hidden='true'
className={className}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap,
placeItems: 'center',
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
))}
</div>
)
}
const TEMPLATES_PANEL_ID = 'templates-panel'
export default function Templates() {
const sectionRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [isPreparingTemplate, setIsPreparingTemplate] = useState(false)
const router = useRouter()
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start 0.9', 'start 0.2'],
})
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
const handleUseTemplate = useCallback(async () => {
if (isPreparingTemplate) return
setIsPreparingTemplate(true)
try {
if (activeWorkflow.seedPath) {
const response = await fetch(activeWorkflow.seedPath)
if (!response.ok) {
throw new Error(`Failed to fetch template seed: ${response.status}`)
}
const workflowJson = await response.text()
LandingWorkflowSeedStorage.store({
templateId: activeWorkflow.id,
workflowName: activeWorkflow.name,
color: activeWorkflow.color,
workflowJson,
})
}
} catch (error) {
logger.error('Failed to prepare landing template workflow seed', {
templateId: activeWorkflow.id,
error,
})
} finally {
setIsPreparingTemplate(false)
router.push('/signup')
}
}, [
activeWorkflow.color,
activeWorkflow.id,
activeWorkflow.name,
activeWorkflow.seedPath,
isPreparingTemplate,
router,
])
return (
<section
ref={sectionRef}
id='templates'
aria-labelledby='templates-heading'
className='mt-[40px] mb-[80px]'
>
<p className='sr-only'>
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
processing, release management, meeting follow-ups, resume scanning, email triage,
competitor monitoring, social listening, data enrichment, feedback analysis, code review,
and knowledge base Q&amp;A. Each template connects real integrations and LLMs pick one,
customise it, and deploy in minutes.
</p>
<div className='bg-[#1C1C1C]'>
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
rows={1}
gap={6}
/>
<div className='relative overflow-hidden'>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-0 z-20 hidden lg:block'
>
<svg
width={329}
height={34}
viewBox='-34 0 329 34'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
>
{SCROLL_BLOCK_RECTS.map((r, i) => (
<ScrollBlockRect key={i} scrollYProgress={scrollYProgress} rect={r} />
))}
</svg>
</div>
<div className='px-[80px] pt-[100px]'>
<div className='flex flex-col items-start gap-[20px]'>
<Badge
variant='blue'
size='md'
dot
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
style={{
color: activeDepth.color,
backgroundColor: hexToRgba(activeDepth.color, 0.1),
}}
>
Templates
</Badge>
<h2
id='templates-heading'
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
>
Ship your agent in minutes
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
Pre-built templates for every use casepick one, swap <br />
models and tools to fit your stack, and deploy.
</p>
</div>
</div>
<div className='mt-[73px] flex border-[#2A2A2A] border-y'>
<DotGrid
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-r p-[6px]'
cols={6}
rows={55}
gap={6}
/>
<div className='flex min-w-0 flex-1'>
<div
role='tablist'
aria-label='Workflow templates'
className='flex w-[300px] shrink-0 flex-col border-[#2A2A2A] border-r'
>
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
const isActive = index === activeIndex
return (
<button
key={workflow.id}
id={`template-tab-${index}`}
type='button'
role='tab'
aria-selected={isActive}
aria-controls={TEMPLATES_PANEL_ID}
onClick={() => setActiveIndex(index)}
className={cn(
'relative text-left',
isActive
? 'z-10'
: 'flex items-center px-[12px] py-[10px] shadow-[inset_0_-1px_0_0_#2A2A2A] last:shadow-none hover:bg-[#232323]/50'
)}
>
{isActive ? (
(() => {
const depth = DEPTH_CONFIGS[workflow.id]
return (
<>
<div
className='absolute top-[-8px] bottom-0 left-0 w-2'
style={{
clipPath: LEFT_WALL_CLIP,
backgroundColor: hexToRgba(depth.color, 0.63),
}}
/>
<div
className='absolute right-[-8px] bottom-0 left-2 h-2'
style={buildBottomWallStyle(depth)}
/>
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
{workflow.name}
</span>
<ChevronDown
className='-rotate-90 h-[11px] w-[11px] shrink-0'
style={{ color: depth.color }}
/>
</div>
</>
)
})()
) : (
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
{workflow.name}
</span>
)}
</button>
)
})}
</div>
<div
id={TEMPLATES_PANEL_ID}
role='tabpanel'
aria-labelledby={`template-tab-${activeIndex}`}
className='relative hidden flex-1 lg:block'
>
<div aria-hidden='true' className='h-full'>
<LandingPreviewWorkflow
key={activeIndex}
workflow={activeWorkflow}
animate
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
/>
</div>
<button
type='button'
onClick={handleUseTemplate}
disabled={isPreparingTemplate}
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
>
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
</button>
</div>
</div>
<DotGrid
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
cols={6}
rows={55}
gap={6}
/>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,18 @@
/**
* Testimonials section — social proof via user quotes.
*
* SEO:
* - `<section id="testimonials" aria-labelledby="testimonials-heading">`.
* - `<h2 id="testimonials-heading">` for the section title.
* - Each testimonial: `<blockquote cite="tweet-url">` with `<footer><cite>Author</cite></footer>`.
* - Profile images use `loading="lazy"` (below the fold).
*
* GEO:
* - Keep quote text as plain text in `<blockquote>` — not split across `<span>` elements.
* - Include full author name + handle (LLMs weigh attributed quotes higher).
* - Testimonials mentioning "Sim" by name carry more citation weight.
* - Review data here aligns with `review` entries in structured-data.tsx.
*/
export default function Testimonials() {
return null
}

View File

@@ -0,0 +1,53 @@
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import {
Collaboration,
Enterprise,
Features,
Footer,
Hero,
Navbar,
Pricing,
StructuredData,
Templates,
Testimonials,
} from '@/app/(home)/components'
/**
* Landing page root component.
*
* ## SEO Architecture
* - Single `<h1>` inside Hero (only one per page).
* - Heading hierarchy: H1 (Hero) -> H2 (each section) -> H3 (sub-items).
* - Semantic landmarks: `<header>`, `<main>`, `<footer>`.
* - Every `<section>` has an `id` for anchor linking and `aria-labelledby` for accessibility.
* - `StructuredData` emits JSON-LD before any visible content.
*
* ## GEO Architecture
* - Above-fold content (Navbar, Hero) is statically rendered (Server Components where possible)
* for immediate availability to AI crawlers.
* - Section `id` attributes serve as fragment anchors for precise AI citations.
* - Content ordering prioritizes answer-first patterns: definition (Hero) ->
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration, Testimonials) ->
* pricing (Pricing) -> enterprise (Enterprise).
*/
export default async function Landing() {
return (
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
<StructuredData />
<header>
<Navbar />
</header>
<main>
<Hero />
<Templates />
<Features />
<Collaboration />
<Pricing />
<Enterprise />
<Testimonials />
</main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
/**
* Landing page route-group layout.
*
* Applies landing-specific font CSS variables to the subtree:
* - `--font-season` (Season Sans): Headings and display text
* - `--font-martian-mono` (Martian Mono): Code snippets and technical accents
*
* Available to child components via Tailwind (`font-season`, `font-martian-mono`).
*
* SEO metadata for the `/` route is exported from `app/page.tsx` — not here.
* This layout only applies when a `page.tsx` exists inside the `(home)/` route group.
*/
export default function HomeLayout({ children }: { children: React.ReactNode }) {
return <div className={`${season.variable} ${martianMono.variable}`}>{children}</div>
}

View File

@@ -11,16 +11,14 @@ import {
Database,
DollarSign,
HardDrive,
RefreshCw,
Timer,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import {
ENTERPRISE_PLAN_FEATURES,
PRO_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
import { ENTERPRISE_PLAN_FEATURES } from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
const logger = createLogger('LandingPricing')
@@ -38,20 +36,30 @@ interface PricingTier {
featured?: boolean
}
/**
* Free plan features with consistent icons
*/
const FREE_PLAN_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '$20 usage limit' },
{ icon: DollarSign, text: '3,000 credits/mo' },
{ icon: HardDrive, text: '5GB file storage' },
{ icon: Timer, text: '5 min execution limit' },
{ icon: Database, text: 'Limited log retention' },
{ icon: Code2, text: 'CLI/SDK Access' },
]
/**
* Available pricing tiers with their features and pricing
*/
const PRO_LANDING_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '6,000 credits/mo' },
{ icon: RefreshCw, text: '+50 daily refresh credits' },
{ icon: Zap, text: '150 runs/min (sync)' },
{ icon: Timer, text: '50 min sync execution limit' },
{ icon: HardDrive, text: '50GB file storage' },
]
const MAX_LANDING_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '25,000 credits/mo' },
{ icon: RefreshCw, text: '+200 daily refresh credits' },
{ icon: Zap, text: '300 runs/min (sync)' },
{ icon: Timer, text: '50 min sync execution limit' },
{ icon: HardDrive, text: '500GB file storage' },
]
const pricingTiers: PricingTier[] = [
{
name: 'COMMUNITY',
@@ -63,16 +71,16 @@ const pricingTiers: PricingTier[] = [
{
name: 'PRO',
tier: 'Pro',
price: '$20/mo',
features: PRO_PLAN_FEATURES,
price: '$25/mo',
features: PRO_LANDING_FEATURES,
ctaText: 'Get Started',
featured: true,
},
{
name: 'TEAM',
tier: 'Team',
price: '$40/mo',
features: TEAM_PLAN_FEATURES,
name: 'MAX',
tier: 'Max',
price: '$100/mo',
features: MAX_LANDING_FEATURES,
ctaText: 'Get Started',
},
{
@@ -84,12 +92,6 @@ const pricingTiers: PricingTier[] = [
},
]
/**
* Individual pricing card component
* @param tier - The pricing tier data
* @param index - The index of the card in the grid
* @param isBeforeFeatured - Whether this card is immediately before a featured card
*/
function PricingCard({
tier,
index,
@@ -106,10 +108,8 @@ function PricingCard({
logger.info(`Pricing CTA clicked: ${tier.name}`)
if (tier.ctaText === 'Contact Sales') {
// Open enterprise form in new tab
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
} else {
// Navigate to signup page for all "Get Started" buttons
router.push('/signup')
}
}

View File

@@ -8,7 +8,7 @@ export default function StructuredData() {
name: 'Sim',
alternateName: 'Sim',
description:
'Open-source AI agent workflow builder used by developers at trail-blazing startups to Fortune 500 companies',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
url: 'https://sim.ai',
logo: {
'@type': 'ImageObject',
@@ -36,9 +36,9 @@ export default function StructuredData() {
'@type': 'WebSite',
'@id': 'https://sim.ai/#website',
url: 'https://sim.ai',
name: 'Sim - AI Agent Workflow Builder',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Open-source AI agent workflow builder. 70,000+ developers build and deploy agentic workflows. SOC2 and HIPAA compliant.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
publisher: {
'@id': 'https://sim.ai/#organization',
},
@@ -48,7 +48,7 @@ export default function StructuredData() {
'@type': 'WebPage',
'@id': 'https://sim.ai/#webpage',
url: 'https://sim.ai',
name: 'Sim - Workflows for LLMs | Build AI Agent Workflows',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
isPartOf: {
'@id': 'https://sim.ai/#website',
},
@@ -58,7 +58,7 @@ export default function StructuredData() {
datePublished: '2024-01-01T00:00:00+00:00',
dateModified: new Date().toISOString(),
description:
'Build and deploy AI agent workflows with Sim. Visual drag-and-drop interface for creating powerful LLM-powered automations.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
breadcrumb: {
'@id': 'https://sim.ai/#breadcrumb',
},
@@ -85,9 +85,9 @@ export default function StructuredData() {
{
'@type': 'SoftwareApplication',
'@id': 'https://sim.ai/#software',
name: 'Sim - AI Agent Workflow Builder',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Open-source AI agent workflow builder used by 70,000+ developers. Build agentic workflows with visual drag-and-drop interface. SOC2 and HIPAA compliant. Integrate with 100+ apps.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'AI Development Tools',
operatingSystem: 'Web, Windows, macOS, Linux',
@@ -159,12 +159,13 @@ export default function StructuredData() {
worstRating: '1',
},
featureList: [
'Visual workflow builder',
'Drag-and-drop interface',
'100+ integrations',
'AI model support (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Real-time collaboration',
'Version control',
'AI agent creation',
'Agentic workflow orchestration',
'1,000+ integrations',
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Knowledge base creation',
'Table creation',
'Document creation',
'API access',
'Custom functions',
'Scheduled workflows',
@@ -174,7 +175,7 @@ export default function StructuredData() {
{
'@type': 'ImageObject',
url: 'https://sim.ai/logo/426-240/primary/small.png',
caption: 'Sim AI agent workflow builder interface',
caption: 'Sim — build AI agents and run your agentic workforce',
},
],
},
@@ -187,7 +188,7 @@ export default function StructuredData() {
name: 'What is Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim is an open-source AI agent workflow builder used by 70,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.',
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
},
},
{
@@ -203,7 +204,7 @@ export default function StructuredData() {
name: 'Do I need coding skills to use Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'No coding skills are required! Sim features a visual drag-and-drop interface that makes it easy to build AI workflows. However, developers can also use custom functions and our API for advanced use cases.',
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
},
},
],

View File

@@ -4,8 +4,8 @@ export const metadata: Metadata = {
metadataBase: new URL('https://sim.ai'),
manifest: '/manifest.json',
icons: {
icon: '/favicon.ico',
apple: '/apple-icon.png',
icon: [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }],
apple: '/favicon/apple-touch-icon.png',
},
other: {
'msapplication-TileColor': '#000000',

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
@@ -5,6 +6,17 @@ import { soehne } from '@/app/_styles/fonts/soehne/soehne'
export const revalidate = 3600
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
return { title: author?.name ?? 'Author' }
}
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)

View File

@@ -1,8 +1,14 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { PostGrid } from '@/app/(landing)/studio/post-grid'
export const metadata: Metadata = {
title: 'Studio',
description: 'Announcements, insights, and guides from the Sim team.',
}
export const revalidate = 3600
export default async function StudioIndex({

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllTags } from '@/lib/blog/registry'
export const metadata: Metadata = {
title: 'Tags',
}
export default async function TagsIndex() {
const tags = await getAllTags()
return (

View File

@@ -0,0 +1,118 @@
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
const DEFAULT_FAVICON = '/icon.svg'
/**
* Sidebar icon color: --text-icon is #5e5e5e (light) / #939393 (dark).
* Favicon needs to be visible on any tab bar, so we use a slightly stronger value.
*/
const ICON_COLOR_LIGHT = '%235e5e5e'
const ICON_COLOR_DARK = '%23939393'
function pad(viewBox: string): string {
const [x, y, w, h] = viewBox.split(' ').map(Number)
const p = Math.max(w, h) * 0.1
return `${x - p} ${y - p} ${w + p * 2} ${h + p * 2}`
}
function wrapStrokeIcon(viewBox: string, paths: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${pad(viewBox)}" fill="none"><style>path,circle,ellipse,rect,line{stroke:${ICON_COLOR_LIGHT};stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round;fill:none}@media(prefers-color-scheme:dark){path,circle,ellipse,rect,line{stroke:${ICON_COLOR_DARK}}}</style>${paths}</svg>`
return `data:image/svg+xml,${svg}`
}
function wrapFilledIcon(viewBox: string, paths: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${pad(viewBox)}" fill="none"><style>.f{fill:${ICON_COLOR_LIGHT};stroke:${ICON_COLOR_LIGHT};stroke-width:0.5;stroke-linejoin:round}@media(prefers-color-scheme:dark){.f{fill:${ICON_COLOR_DARK};stroke:${ICON_COLOR_DARK}}}</style>${paths}</svg>`
return `data:image/svg+xml,${svg}`
}
const FAVICONS: Record<string, string> = {
home: wrapStrokeIcon(
'-1 -2 24 24',
`<path d="M0.75 9.5L10.25 1L19.75 9.5V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V9.5Z"/><path d="M7.25 18.75V13C7.25 12.4477 7.69772 12 8.25 12H12.25C12.8023 12 13.25 12.4477 13.25 13V18.75"/>`
),
blimp: wrapFilledIcon(
'1.25 4 18 13',
`<path class="f" d="M18.24 9.18C18.16 8.94 18 8.74 17.83 8.56L17.83 8.56C17.67 8.4 17.49 8.25 17.3 8.11V5.48C17.3 5.32 17.24 5.17 17.14 5.06C17.06 4.95 16.93 4.89 16.79 4.89H15.93C15.61 4.89 15.32 5.11 15.19 5.44L14.68 6.77C14.05 6.51 13.23 6.22 12.15 6C11.04 5.77 9.66 5.61 7.9 5.61C5.97 5.61 4.56 6.13 3.61 6.89C3.14 7.28 2.78 7.72 2.54 8.19C2.29 8.66 2.18 9.15 2.18 9.63C2.18 10.1 2.29 10.59 2.52 11.06C2.87 11.76 3.48 12.41 4.34 12.89C4.91 13.2 5.61 13.44 6.43 13.56L6.8 14.78C6.94 15.27 7.33 15.59 7.78 15.59H10.56C11.06 15.59 11.48 15.18 11.58 14.61L11.81 13.29C12.31 13.2 12.75 13.09 13.14 12.99C13.74 12.82 14.24 12.64 14.67 12.48L15.19 13.82C15.32 14.16 15.61 14.38 15.93 14.38H16.79C16.93 14.38 17.06 14.31 17.14 14.2C17.24 14.1 17.29 13.95 17.3 13.79V11.15C17.33 11.12 17.37 11.09 17.42 11.07L17.4 11.07L17.42 11.07C17.65 10.89 17.87 10.69 18.04 10.46C18.12 10.35 18.19 10.22 18.24 10.08C18.29 9.94 18.32 9.79 18.32 9.63C18.32 9.47 18.29 9.32 18.24 9.18ZM15.69 5.71C15.73 5.6 15.83 5.53 15.93 5.53H16.74V7.89C16.41 7.7 16.06 7.53 15.71 7.37C15.55 7.29 15.37 7.2 15.15 7.1L15.69 5.71ZM11.05 14.48C11 14.76 10.79 14.95 10.56 14.95H7.78C7.56 14.95 7.38 14.79 7.31 14.56L6.99 13.52C7.22 13.54 7.47 13.55 7.73 13.55C7.79 13.55 7.84 13.55 7.9 13.55C9.05 13.53 10.05 13.45 10.9 13.33C11.02 13.31 11.14 13.29 11.26 13.27L11.05 14.48ZM16.74 13.74H15.93C15.83 13.74 15.73 13.66 15.69 13.56L15.15 12.16C15.36 12.06 15.55 11.97 15.71 11.9C16.06 11.73 16.41 11.56 16.74 11.37V13.74ZM17.75 9.83C17.7 9.95 17.61 10.08 17.48 10.22C17.4 10.3 17.3 10.38 17.2 10.46C17.07 10.57 16.91 10.67 16.74 10.77C16.71 10.8 16.67 10.82 16.63 10.84C16.29 11.04 15.91 11.23 15.55 11.4C15.38 11.48 15.18 11.57 14.96 11.67C14.82 11.73 14.68 11.79 14.53 11.85C14.12 12.02 13.62 12.2 13.02 12.36C12.65 12.46 12.24 12.56 11.79 12.64C11.65 12.67 11.51 12.7 11.36 12.72C10.4 12.88 9.26 12.99 7.9 13.01C7.84 13.02 7.79 13.02 7.73 13.02C7.41 13.02 7.11 13 6.82 12.97C6.65 12.95 6.48 12.93 6.32 12.9C5.26 12.71 4.45 12.32 3.88 11.84C3.48 11.5 3.19 11.12 2.99 10.74C2.8 10.36 2.72 9.98 2.72 9.63C2.72 9.28 2.81 8.9 3 8.52C3.3 7.95 3.82 7.38 4.63 6.95C5.44 6.53 6.52 6.25 7.9 6.25C10.2 6.25 11.84 6.53 13.05 6.87C13.64 7.04 14.13 7.22 14.53 7.39L14.54 7.4C14.69 7.46 14.83 7.52 14.96 7.59C15.18 7.69 15.37 7.78 15.55 7.86C15.95 8.06 16.38 8.27 16.74 8.49C16.85 8.56 16.96 8.62 17.06 8.69C17.08 8.71 17.1 8.72 17.12 8.74C17.34 8.9 17.51 9.06 17.62 9.22C17.68 9.29 17.72 9.37 17.75 9.44C17.77 9.5 17.78 9.57 17.78 9.63C17.78 9.7 17.77 9.76 17.75 9.83Z"/>`
),
table: wrapStrokeIcon(
'-1 -2 24 24',
`<path d="M0.75 3.25C0.75 1.86929 1.86929 0.75 3.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V3.25Z"/><path d="M0.75 6.75H19.75"/><path d="M0.75 12.75H19.75"/><path d="M10.25 0.75V18.75"/>`
),
files: wrapStrokeIcon(
'-1 -2 24 24',
`<path d="M12.25 0.75H5.25C4.14543 0.75 3.25 1.64543 3.25 2.75V16.75C3.25 17.8546 4.14543 18.75 5.25 18.75H15.25C16.3546 18.75 17.25 17.8546 17.25 16.75V5.75L12.25 0.75Z"/><path d="M12.25 0.75V5.75H17.25"/>`
),
knowledge: wrapStrokeIcon(
'-1 -2 24 24',
`<ellipse cx="10.25" cy="3.75" rx="8.5" ry="3"/><path d="M1.75 3.75V9.75C1.75 11.41 5.55 12.75 10.25 12.75C14.95 12.75 18.75 11.41 18.75 9.75V3.75"/><path d="M1.75 9.75V15.75C1.75 17.41 5.55 18.75 10.25 18.75C14.95 18.75 18.75 17.41 18.75 15.75V9.75"/>`
),
calendar: wrapStrokeIcon(
'-1 -2 24 24',
`<path d="M0.75 5.25C0.75 3.86929 1.86929 2.75 3.25 2.75H17.25C18.6307 2.75 19.75 3.86929 19.75 5.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V5.25Z"/><path d="M0.75 8.25H19.75"/><path d="M6.25 0.25V5.25"/><path d="M14.25 0.25V5.25"/>`
),
library: wrapStrokeIcon(
'-1 -2 24 24',
`<path d="M0.75 4.75C0.75 3.34987 0.75 2.6498 1.02248 2.11502C1.26217 1.64462 1.64462 1.26217 2.11502 1.02248C2.6498 0.75 3.34987 0.75 4.75 0.75C6.15013 0.75 6.8502 0.75 7.38498 1.02248C7.85538 1.26217 8.23783 1.64462 8.47752 2.11502C8.75 2.6498 8.75 3.34987 8.75 4.75V14.75C8.75 16.1501 8.75 16.8502 8.47752 17.385C8.23783 17.8554 7.85538 18.2378 7.38498 18.4775C6.8502 18.75 6.15013 18.75 4.75 18.75C3.34987 18.75 2.6498 18.75 2.11502 18.4775C1.64462 18.2378 1.26217 17.8554 1.02248 17.385C0.75 16.8502 0.75 16.1501 0.75 14.75V4.75Z"/><path d="M0.75 6.75H8.75"/><path d="M10.1986 6.01843C9.84373 4.68838 9.66628 4.02336 9.78849 3.44599C9.89599 2.93812 10.1608 2.47747 10.5451 2.13005C10.9819 1.73508 11.6442 1.55689 12.9687 1.2005C14.2932 0.844119 14.9555 0.665926 15.5304 0.788649C16.0362 0.8966 16.4949 1.16256 16.8409 1.54841C17.2342 1.98706 17.4117 2.65209 17.7666 3.98213L20.3014 13.4816C20.6563 14.8116 20.8337 15.4766 20.7115 16.054C20.604 16.5619 20.3392 17.0225 19.9549 17.37C19.5181 17.7649 18.8558 17.9431 17.5313 18.2995C16.2068 18.6559 15.5445 18.8341 14.9696 18.7114C14.4638 18.6034 14.0051 18.3374 13.6591 17.9516C13.2658 17.5129 13.0883 16.8479 12.7334 15.5179L10.1986 6.01843Z"/><path d="M10.75 7.75L17.2501 5.75"/>`
),
settings: wrapStrokeIcon(
'0 0 24 24',
`<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>`
),
templates: wrapStrokeIcon(
'1 1 22 22',
`<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>`
),
}
function getRouteSection(pathname: string): string | null {
if (!pathname.includes('/workspace/')) return null
const parts = pathname.split('/')
const sectionIndex = parts.indexOf('workspace') + 2
return parts[sectionIndex] ?? null
}
const SECTION_TO_ICON: Record<string, string> = {
home: 'home',
task: 'home',
w: 'blimp',
tables: 'table',
files: 'files',
knowledge: 'knowledge',
schedules: 'calendar',
logs: 'library',
settings: 'settings',
templates: 'templates',
}
function setFaviconHrefs(url: string) {
document.querySelectorAll<HTMLLinkElement>("link[rel*='icon']").forEach((link) => {
if (link.rel === 'apple-touch-icon') return
link.href = url
})
}
export function DynamicFavicon() {
const pathname = usePathname()
useEffect(() => {
const section = getRouteSection(pathname)
const iconKey = section ? SECTION_TO_ICON[section] : null
const url = iconKey ? FAVICONS[iconKey] : DEFAULT_FAVICON
setFaviconHrefs(url)
// Re-apply whenever Next.js head reconciliation replaces link elements
const observer = new MutationObserver(() => setFaviconHrefs(url))
observer.observe(document.head, { childList: true })
return () => {
observer.disconnect()
setFaviconHrefs(DEFAULT_FAVICON)
}
}, [pathname])
return null
}

View File

@@ -15,26 +15,41 @@ const BROWSER_EXTENSION_ATTRIBUTES = [
]
/**
* Client component that intercepts console.error to filter and log hydration errors
* while ignoring errors caused by browser extensions.
* Checks whether a hydration error is caused by Radix UI's auto-generated IDs
* (`aria-controls`, `id`) differing between server and client. This is a known
* harmless artifact of React 19's streaming SSR producing different `useId()`
* tree paths. The IDs self-correct on first interaction and have no visual or
* functional impact since they only link closed (invisible) popover/menu content.
*/
function isRadixIdMismatch(args: unknown[]): boolean {
return args.some(
(arg) => typeof arg === 'string' && arg.includes('radix-') && /aria-controls|"\bid\b"/.test(arg)
)
}
/**
* Client component that intercepts console.error to filter hydration errors
* caused by browser extensions or Radix UI's `useId()` mismatch.
*/
export function HydrationErrorHandler() {
useEffect(() => {
const originalError = console.error
console.error = (...args) => {
if (args[0].includes('Hydration')) {
if (typeof args[0] === 'string' && args[0].includes('Hydration')) {
const isExtensionError = BROWSER_EXTENSION_ATTRIBUTES.some((attr) =>
args.some((arg) => typeof arg === 'string' && arg.includes(attr))
)
if (!isExtensionError) {
logger.error('Hydration Error', {
details: args,
componentStack: args.find(
(arg) => typeof arg === 'string' && arg.includes('component stack')
),
})
if (isExtensionError || isRadixIdMismatch(args)) {
return
}
logger.error('Hydration Error', {
details: args,
componentStack: args.find(
(arg) => typeof arg === 'string' && arg.includes('component stack')
),
})
}
originalError.apply(console, args)
}

View File

@@ -1,41 +1,64 @@
'use client'
import { useEffect } from 'react'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { PostHog } from 'posthog-js'
import { getEnv, isTruthy } from '@/lib/core/config/env'
const logger = createLogger('PostHogProvider')
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const [Provider, setProvider] = useState<React.ComponentType<{
client: PostHog
children: React.ReactNode
}> | null>(null)
const clientRef = useRef<PostHog | null>(null)
useEffect(() => {
const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY')
if (isTruthy(posthogEnabled) && posthogKey && !posthog.__loaded) {
posthog.init(posthogKey, {
api_host: '/ingest',
ui_host: 'https://us.posthog.com',
defaults: '2025-05-24',
person_profiles: 'identified_only',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
capture_performance: false,
capture_dead_clicks: false,
enable_heatmaps: false,
session_recording: {
maskAllInputs: false,
maskInputOptions: {
password: true,
email: false,
},
recordCrossOriginIframes: false,
recordHeaders: false,
recordBody: false,
},
persistence: 'localStorage+cookie',
if (!isTruthy(posthogEnabled) || !posthogKey) return
Promise.all([import('posthog-js'), import('posthog-js/react')])
.then(([posthogModule, { PostHogProvider: PHProvider }]) => {
const posthog = posthogModule.default
if (!posthog.__loaded) {
posthog.init(posthogKey, {
api_host: '/ingest',
ui_host: 'https://us.posthog.com',
defaults: '2025-05-24',
person_profiles: 'identified_only',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
capture_performance: false,
capture_dead_clicks: false,
enable_heatmaps: false,
session_recording: {
maskAllInputs: false,
maskInputOptions: {
password: true,
email: false,
},
recordCrossOriginIframes: false,
recordHeaders: false,
recordBody: false,
},
persistence: 'localStorage+cookie',
})
}
clientRef.current = posthog
setProvider(() => PHProvider)
})
.catch((err) => {
logger.error('Failed to load PostHog', { error: err })
})
}
}, [])
return <PHProvider client={posthog}>{children}</PHProvider>
if (Provider && clientRef.current) {
return <Provider client={clientRef.current}>{children}</Provider>
}
return <>{children}</>
}

View File

@@ -3,7 +3,6 @@
import type React from 'react'
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import posthog from 'posthog-js'
import { client } from '@/lib/auth/auth-client'
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
@@ -77,22 +76,26 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}, [loadSession, queryClient])
useEffect(() => {
if (isPending || typeof posthog.identify !== 'function') {
return
}
if (isPending) return
try {
if (data?.user) {
posthog.identify(data.user.id, {
email: data.user.email,
name: data.user.name,
email_verified: data.user.emailVerified,
created_at: data.user.createdAt,
})
} else {
posthog.reset()
}
} catch {}
import('posthog-js')
.then(({ default: posthog }) => {
try {
if (typeof posthog.identify !== 'function') return
if (data?.user) {
posthog.identify(data.user.id, {
email: data.user.email,
name: data.user.name,
email_verified: data.user.emailVerified,
created_at: data.user.createdAt,
})
} else {
posthog.reset()
}
} catch {}
})
.catch(() => {})
}, [data, isPending])
const value = useMemo<SessionHookResult>(

View File

@@ -28,7 +28,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute='class'
defaultTheme='dark'
defaultTheme='system'
enableSystem
disableTransitionOnChange
storageKey='sim-theme'

View File

@@ -0,0 +1,14 @@
import { Martian_Mono } from 'next/font/google'
/**
* Martian Mono font configuration
* Monospaced variable font used for code snippets, technical content, and accent text
* on the landing page. Supports weights 100-800.
*/
export const martianMono = Martian_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-martian-mono',
weight: 'variable',
fallback: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
})

View File

@@ -10,7 +10,7 @@
* @see stores/constants.ts for the source of truth
*/
:root {
--sidebar-width: 232px; /* SIDEBAR_WIDTH.DEFAULT */
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
@@ -86,24 +86,29 @@
:root,
.light {
--bg: #fefefe; /* main canvas - neutral near-white */
--surface-1: #fefefe; /* sidebar, panels */
--surface-1: #f9f9f9; /* sidebar, panels */
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
--surface-3: #f7f7f7; /* popovers, headers */
--surface-4: #f5f5f5; /* buttons base */
--border: #e0e0e0; /* primary border */
--border: #dedede; /* primary border */
--surface-5: #f3f3f3; /* inputs, form elements */
--border-1: #e0e0e0; /* stronger border */
--surface-6: #f0f0f0; /* popovers, elevated surfaces */
--surface-7: #ececec;
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
--surface-7: #d9d9d9;
--surface-active: #e8e8e8; /* hover/active state */
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
/* Text - neutral */
--text-primary: #2d2d2d;
--text-secondary: #404040;
--text-primary: #1a1a1a;
--text-secondary: #525252;
--text-tertiary: #5c5c5c;
--text-muted: #737373;
--text-muted: #707070;
--text-subtle: #8c8c8c;
--text-body: #3b3b3b;
--text-icon: #5e5e5e;
--sidebar-font-weight: 420;
--text-inverse: #ffffff;
--text-muted-inverse: #a0a0a0;
--text-error: #ef4444;
@@ -118,6 +123,7 @@
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--selection: #1a5cf6;
--warning: #ea580c;
/* Utility */
@@ -125,7 +131,7 @@
/* Font weights - lighter for light mode */
--font-weight-base: 430;
--font-weight-medium: 450;
--font-weight-medium: 440;
--font-weight-semibold: 500;
/* Extended palette */
@@ -207,11 +213,12 @@
--surface-2: #232323;
--surface-3: #242424;
--surface-4: #292929;
--border: #2c2c2c;
--border: #333333;
--surface-5: #363636;
--border-1: #3d3d3d;
--surface-6: #454545;
--surface-7: #454545;
--surface-7: #505050;
--surface-active: #303030; /* hover/active state */
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
@@ -221,6 +228,10 @@
--text-tertiary: #b3b3b3;
--text-muted: #787878;
--text-subtle: #7d7d7d;
--text-body: #cdcdcd;
--text-icon: #939393;
--sidebar-font-weight: 420;
--text-inverse: #1b1b1b;
--text-muted-inverse: #b3b3b3;
--text-error: #ef4444;
@@ -235,13 +246,14 @@
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--selection: #4b83f7;
--warning: #ff6600;
/* Utility */
--white: #ffffff;
/* Font weights - standard weights for dark mode */
--font-weight-base: 440;
--font-weight-base: 450;
--font-weight-medium: 480;
--font-weight-semibold: 550;

View File

@@ -6,6 +6,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
/**
* Gets the effective billing blocked status for a user.
@@ -43,27 +45,42 @@ async function getEffectiveBillingStatus(userId: string): Promise<{
.from(member)
.where(eq(member.userId, userId))
for (const m of memberships) {
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0 && owners[0].userId !== userId) {
const ownerStats = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, owners[0].userId))
// Fetch all org owners in parallel
const ownerResults = await Promise.all(
memberships.map((m) =>
db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
.limit(1)
)
)
if (ownerStats.length > 0 && ownerStats[0].blocked) {
// Collect owner IDs that are not the current user
const otherOwnerIds = ownerResults
.filter((owners) => owners.length > 0 && owners[0].userId !== userId)
.map((owners) => owners[0].userId)
if (otherOwnerIds.length > 0) {
// Fetch all owner stats in parallel
const ownerStatsResults = await Promise.all(
otherOwnerIds.map((ownerId) =>
db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, ownerId))
.limit(1)
)
)
for (const stats of ownerStatsResults) {
if (stats.length > 0 && stats[0].blocked) {
return {
billingBlocked: true,
billingBlockedReason: ownerStats[0].blockedReason,
billingBlockedReason: stats[0].blockedReason,
blockedByOrgOwner: true,
}
}
@@ -114,11 +131,12 @@ export async function GET(request: NextRequest) {
let billingData
if (context === 'user') {
// Get user billing (may include organization if they're part of one)
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
// Attach effective billing blocked status (includes org owner check)
const billingStatus = await getEffectiveBillingStatus(session.user.id)
// Get user billing and billing blocked status in parallel
const [billingResult, billingStatus] = await Promise.all([
getSimplifiedBillingSummary(session.user.id, contextId || undefined),
getEffectiveBillingStatus(session.user.id),
])
billingData = billingResult
billingData = {
...billingData,
@@ -188,10 +206,17 @@ export async function GET(request: NextRequest) {
averageUsagePerMember: rawBillingData.averageUsagePerMember,
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
members: rawBillingData.members.map((member) => ({
...member,
joinedAt: member.joinedAt.toISOString(),
lastActive: member.lastActive?.toISOString() || null,
tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan),
totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage),
totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit),
minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount),
averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember),
members: rawBillingData.members.map((m) => ({
...m,
joinedAt: m.joinedAt.toISOString(),
lastActive: m.lastActive?.toISOString() || null,
currentUsageCredits: dollarsToCredits(m.currentUsage),
usageLimitCredits: dollarsToCredits(m.usageLimit),
})),
}

View File

@@ -0,0 +1,174 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('SwitchPlan')
const switchPlanSchema = z.object({
targetPlanName: z.string(),
interval: z.enum(['month', 'year']).optional(),
})
/**
* POST /api/billing/switch-plan
*
* Switches a subscription's tier and/or billing interval via direct Stripe API.
* Covers: Pro <-> Max, monthly <-> annual, and team tier changes.
* Uses proration -- no Billing Portal redirect.
*
* Body:
* targetPlanName: string -- e.g. 'pro_6000', 'team_25000'
* interval?: 'month' | 'year' -- if omitted, keeps the current interval
*/
export async function POST(request: NextRequest) {
const session = await getSession()
try {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!isBillingEnabled) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 })
}
const body = await request.json()
const parsed = switchPlanSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { targetPlanName, interval } = parsed.data
const userId = session.user.id
const sub = await getHighestPrioritySubscription(userId)
if (!sub || !sub.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
return NextResponse.json(
{ error: 'Enterprise plan changes must be handled via support' },
{ status: 400 }
)
}
const targetPlan = getPlanByName(targetPlanName)
if (!targetPlan) {
return NextResponse.json({ error: 'Target plan not found' }, { status: 400 })
}
const currentPlanType = getPlanType(sub.plan)
const targetPlanType = getPlanType(targetPlanName)
if (currentPlanType !== targetPlanType) {
return NextResponse.json(
{ error: 'Cannot switch between individual and team plans via this endpoint' },
{ status: 400 }
)
}
if (isOrgPlan(sub.plan)) {
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
if (!hasPermission) {
return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 })
}
}
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
if (stripeSubscription.status !== 'active') {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}
const subscriptionItem = stripeSubscription.items.data[0]
if (!subscriptionItem) {
return NextResponse.json({ error: 'No subscription item found in Stripe' }, { status: 500 })
}
const currentInterval = subscriptionItem.price?.recurring?.interval
const targetInterval = interval ?? currentInterval ?? 'month'
const targetPriceId =
targetInterval === 'year' ? targetPlan.annualDiscountPriceId : targetPlan.priceId
if (!targetPriceId) {
return NextResponse.json(
{ error: `No ${targetInterval} price configured for plan ${targetPlanName}` },
{ status: 400 }
)
}
const alreadyOnStripePrice = subscriptionItem.price?.id === targetPriceId
const alreadyInDb = sub.plan === targetPlanName
if (alreadyOnStripePrice && alreadyInDb) {
return NextResponse.json({ success: true, message: 'Already on this plan and interval' })
}
logger.info('Switching subscription', {
userId,
subscriptionId: sub.id,
stripeSubscriptionId: sub.stripeSubscriptionId,
fromPlan: sub.plan,
toPlan: targetPlanName,
fromInterval: currentInterval,
toInterval: targetInterval,
targetPriceId,
})
if (!alreadyOnStripePrice) {
const currentQuantity = subscriptionItem.quantity ?? 1
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
items: [
{
id: subscriptionItem.id,
price: targetPriceId,
quantity: currentQuantity,
},
],
proration_behavior: 'create_prorations',
})
}
if (!alreadyInDb) {
await db
.update(subscriptionTable)
.set({ plan: targetPlanName })
.where(eq(subscriptionTable.id, sub.id))
}
logger.info('Subscription switched successfully', {
userId,
subscriptionId: sub.id,
fromPlan: sub.plan,
toPlan: targetPlanName,
interval: targetInterval,
})
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
} catch (error) {
logger.error('Failed to switch subscription', {
userId: session?.user?.id,
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to switch plan' },
{ status: 500 }
)
}
}

View File

@@ -18,7 +18,9 @@ const UpdateCostSchema = z.object({
model: z.string().min(1, 'Model is required'),
inputTokens: z.number().min(0).default(0),
outputTokens: z.number().min(0).default(0),
source: z.enum(['copilot', 'mcp_copilot']).default('copilot'),
source: z
.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block'])
.default('copilot'),
})
/**
@@ -98,19 +100,22 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
}
const totalTokens = inputTokens + outputTokens
const updateFields: Record<string, unknown> = {
totalCost: sql`total_cost + ${cost}`,
currentPeriodCost: sql`current_period_cost + ${cost}`,
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
totalCopilotCalls: sql`total_copilot_calls + 1`,
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
lastActive: new Date(),
}
// Also increment MCP-specific counters when source is mcp_copilot
if (isMcp) {
updateFields.totalMcpCopilotCost = sql`total_mcp_copilot_cost + ${cost}`
updateFields.currentPeriodMcpCopilotCost = sql`current_period_mcp_copilot_cost + ${cost}`
updateFields.totalMcpCopilotCalls = sql`total_mcp_copilot_calls + 1`
}
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
@@ -121,10 +126,10 @@ export async function POST(req: NextRequest) {
source,
})
// Log usage for complete audit trail
// Log usage for complete audit trail with the original source for visibility
await logModelUsage({
userId,
source: isMcp ? 'mcp_copilot' : 'copilot',
source,
model,
inputTokens,
outputTokens,

View File

@@ -106,23 +106,16 @@ export async function POST(request: NextRequest) {
)
}
// Check if identifier is available
const existingIdentifier = await db
.select()
.from(chat)
.where(eq(chat.identifier, identifier))
.limit(1)
// Check identifier availability and workflow access in parallel
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
db.select().from(chat).where(eq(chat.identifier, identifier)).limit(1),
checkWorkflowAccessForChatCreation(workflowId, session.user.id),
])
if (existingIdentifier.length > 0) {
return createErrorResponse('Identifier already in use', 400)
}
// Check if user has permission to create chat for this workflow
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
workflowId,
session.user.id
)
if (!hasAccess || !workflowRecord) {
return createErrorResponse('Workflow not found or access denied', 404)
}

View File

@@ -1,12 +1,22 @@
import { db } from '@sim/db'
import { settings } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotAutoAllowedToolsAPI')
/** Headers for server-to-server calls to the Go copilot backend. */
function copilotHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
return headers
}
/**
* GET - Fetch user's auto-allowed integration tools
*/
@@ -20,24 +30,18 @@ export async function GET() {
const userId = session.user.id
const [userSettings] = await db
.select()
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
const res = await fetch(
`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}`,
{ method: 'GET', headers: copilotHeaders() }
)
if (userSettings) {
const autoAllowedTools = (userSettings.copilotAutoAllowedTools as string[]) || []
return NextResponse.json({ autoAllowedTools })
if (!res.ok) {
logger.warn('Go backend returned error for list auto-allowed', { status: res.status })
return NextResponse.json({ autoAllowedTools: [] })
}
await db.insert(settings).values({
id: userId,
userId,
copilotAutoAllowedTools: [],
})
return NextResponse.json({ autoAllowedTools: [] })
const payload = await res.json()
return NextResponse.json({ autoAllowedTools: payload?.autoAllowedTools || [] })
} catch (error) {
logger.error('Failed to fetch auto-allowed tools', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
@@ -62,38 +66,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'toolId must be a string' }, { status: 400 })
}
const toolId = body.toolId
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (existing) {
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
if (!currentTools.includes(toolId)) {
const updatedTools = [...currentTools, toolId]
await db
.update(settings)
.set({
copilotAutoAllowedTools: updatedTools,
updatedAt: new Date(),
})
.where(eq(settings.userId, userId))
logger.info('Added tool to auto-allowed list', { userId, toolId })
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
}
return NextResponse.json({ success: true, autoAllowedTools: currentTools })
}
await db.insert(settings).values({
id: userId,
userId,
copilotAutoAllowedTools: [toolId],
const res = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, {
method: 'POST',
headers: copilotHeaders(),
body: JSON.stringify({ userId, toolId: body.toolId }),
})
logger.info('Created settings and added tool to auto-allowed list', { userId, toolId })
return NextResponse.json({ success: true, autoAllowedTools: [toolId] })
if (!res.ok) {
logger.warn('Go backend returned error for add auto-allowed', { status: res.status })
return NextResponse.json({ error: 'Failed to add tool' }, { status: 500 })
}
const payload = await res.json()
return NextResponse.json({
success: true,
autoAllowedTools: payload?.autoAllowedTools || [],
})
} catch (error) {
logger.error('Failed to add auto-allowed tool', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
@@ -119,25 +107,21 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 })
}
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
const res = await fetch(
`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`,
{ method: 'DELETE', headers: copilotHeaders() }
)
if (existing) {
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
const updatedTools = currentTools.filter((t) => t !== toolId)
await db
.update(settings)
.set({
copilotAutoAllowedTools: updatedTools,
updatedAt: new Date(),
})
.where(eq(settings.userId, userId))
logger.info('Removed tool from auto-allowed list', { userId, toolId })
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
if (!res.ok) {
logger.warn('Go backend returned error for remove auto-allowed', { status: res.status })
return NextResponse.json({ error: 'Failed to remove tool' }, { status: 500 })
}
return NextResponse.json({ success: true, autoAllowedTools: [] })
const payload = await res.json()
return NextResponse.json({
success: true,
autoAllowedTools: payload?.autoAllowedTools || [],
})
} catch (error) {
logger.error('Failed to remove auto-allowed tool', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

View File

@@ -0,0 +1,49 @@
import { db } from '@sim/db'
import { copilotChats } 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 { getSession } from '@/lib/auth'
const logger = createLogger('RenameChatAPI')
const RenameChatSchema = z.object({
chatId: z.string().min(1),
title: z.string().min(1).max(200),
})
export async function PATCH(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { chatId, title } = RenameChatSchema.parse(body)
const [updated] = await db
.update(copilotChats)
.set({ title, updatedAt: new Date() })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
.returning({ id: copilotChats.id })
if (!updated) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
logger.info('Chat renamed', { chatId, title })
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error('Error renaming chat:', error)
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
}
}

View File

@@ -5,17 +5,15 @@ import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { buildConversationHistory } from '@/lib/copilot/chat-context'
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import {
createSSEStream,
requestChatTitle,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import {
createStreamEventWriter,
resetStreamBuffer,
setStreamMeta,
} from '@/lib/copilot/orchestrator/stream-buffer'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -23,54 +21,11 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CopilotChatAPI')
async function requestChatTitleFromCopilot(params: {
message: string
model: string
provider?: string
}): Promise<string | null> {
const { message, model, provider } = params
if (!message || !model) return null
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
try {
const response = await fetch(`${SIM_AGENT_API_URL}/api/generate-chat-title`, {
method: 'POST',
headers,
body: JSON.stringify({
message,
model,
...(provider ? { provider } : {}),
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
logger.warn('Failed to generate chat title via copilot backend', {
status: response.status,
error: payload,
})
return null
}
const title = typeof payload?.title === 'string' ? payload.title.trim() : ''
return title || null
} catch (error) {
logger.error('Error generating chat title:', error)
return null
}
}
const FileAttachmentSchema = z.object({
id: z.string(),
key: z.string(),
@@ -81,9 +36,10 @@ const FileAttachmentSchema = z.object({
const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(), // ID from frontend for the user message
userMessageId: z.string().optional(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
workflowName: z.string().optional(),
model: z.string().optional().default('claude-opus-4-5'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
@@ -93,7 +49,6 @@ const ChatMessageSchema = z.object({
implicitFeedback: z.string().optional(),
fileAttachments: z.array(FileAttachmentSchema).optional(),
provider: z.string().optional(),
conversationId: z.string().optional(),
contexts: z
.array(
z.object({
@@ -116,11 +71,11 @@ const ChatMessageSchema = z.object({
blockIds: z.array(z.string()).optional(),
templateId: z.string().optional(),
executionId: z.string().optional(),
// For workflow_block, provide both workflowId and blockId
})
)
.optional(),
commands: z.array(z.string()).optional(),
userTimezone: z.string().optional(),
})
/**
@@ -155,9 +110,9 @@ export async function POST(req: NextRequest) {
implicitFeedback,
fileAttachments,
provider,
conversationId,
contexts,
commands,
userTimezone,
} = ChatMessageSchema.parse(body)
const normalizedContexts = Array.isArray(contexts)
@@ -174,7 +129,7 @@ export async function POST(req: NextRequest) {
})
: contexts
// Resolve workflowId - if not provided, use first workflow or find by name
// Copilot route always requires a workflow scope
const resolved = await resolveWorkflowIdForUser(
authenticatedUserId,
providedWorkflowId,
@@ -186,11 +141,22 @@ export async function POST(req: NextRequest) {
)
}
const workflowId = resolved.workflowId
const workflowResolvedName = resolved.workflowName
// Resolve workspace from workflow so it can be sent as implicit context to the Go backend.
let resolvedWorkspaceId: string | undefined
try {
const { getWorkflowById } = await import('@/lib/workflows/utils')
const wf = await getWorkflowById(workflowId)
resolvedWorkspaceId = wf?.workspaceId ?? undefined
} catch {
logger.warn(`[${tracker.requestId}] Failed to resolve workspaceId from workflow`)
}
// Ensure we have a consistent user message ID for this request
const userMessageIdToUse = userMessageId || crypto.randomUUID()
try {
logger.info(`[${tracker.requestId}] Received chat POST`, {
workflowId,
hasContexts: Array.isArray(normalizedContexts),
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
contextsPreview: Array.isArray(normalizedContexts)
@@ -204,7 +170,7 @@ export async function POST(req: NextRequest) {
: undefined,
})
} catch {}
// Preprocess contexts server-side
let agentContexts: Array<{ type: string; content: string }> = []
if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) {
try {
@@ -234,7 +200,6 @@ export async function POST(req: NextRequest) {
}
}
// Handle chat context
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
@@ -249,27 +214,30 @@ export async function POST(req: NextRequest) {
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId
const history = buildConversationHistory(
chatResult.conversationHistory,
(chatResult.chat?.conversationId as string | undefined) || conversationId
)
conversationHistory = history.history
conversationHistory = Array.isArray(chatResult.conversationHistory)
? chatResult.conversationHistory
: []
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const effectiveConversationId =
(currentChat?.conversationId as string | undefined) || conversationId
const userPermission = resolvedWorkspaceId
? await getUserEntityPermissions(authenticatedUserId, 'workspace', resolvedWorkspaceId).catch(
() => null
)
: null
const requestPayload = await buildCopilotRequestPayload(
{
message,
workflowId,
workflowId: workflowId || '',
workflowName: workflowResolvedName,
workspaceId: resolvedWorkspaceId,
userId: authenticatedUserId,
userMessageId: userMessageIdToUse,
mode,
model: selectedModel,
provider,
conversationId: effectiveConversationId,
conversationHistory,
contexts: agentContexts,
fileAttachments,
@@ -277,6 +245,8 @@ export async function POST(req: NextRequest) {
chatId: actualChatId,
prefetch,
implicitFeedback,
userPermission: userPermission ?? undefined,
userTimezone,
},
{
selectedModel,
@@ -287,7 +257,6 @@ export async function POST(req: NextRequest) {
logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
hasContext: agentContexts.length > 0,
contextCount: agentContexts.length,
hasConversationId: !!effectiveConversationId,
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
messageLength: message.length,
mode: effectiveMode,
@@ -302,138 +271,35 @@ export async function POST(req: NextRequest) {
} catch {}
if (stream) {
const streamId = userMessageIdToUse
let eventWriter: ReturnType<typeof createStreamEventWriter> | null = null
let clientDisconnected = false
const transformedStream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
await resetStreamBuffer(streamId)
await setStreamMeta(streamId, { status: 'active', userId: authenticatedUserId })
eventWriter = createStreamEventWriter(streamId)
const shouldFlushEvent = (event: Record<string, any>) =>
event.type === 'tool_call' ||
event.type === 'tool_result' ||
event.type === 'tool_error' ||
event.type === 'subagent_end' ||
event.type === 'structured_result' ||
event.type === 'subagent_result' ||
event.type === 'done' ||
event.type === 'error'
const pushEvent = async (event: Record<string, any>) => {
if (!eventWriter) return
const entry = await eventWriter.write(event)
if (shouldFlushEvent(event)) {
await eventWriter.flush()
}
const payload = {
...event,
eventId: entry.eventId,
streamId,
}
try {
if (!clientDisconnected) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
}
} catch {
clientDisconnected = true
await eventWriter.flush()
}
}
if (actualChatId) {
await pushEvent({ type: 'chat_id', chatId: actualChatId })
}
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
.then(async (title) => {
if (title) {
await db
.update(copilotChats)
.set({
title,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
await pushEvent({ type: 'title_updated', title })
}
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
}
try {
const result = await orchestrateCopilotStream(requestPayload, {
userId: authenticatedUserId,
workflowId,
chatId: actualChatId,
autoExecuteTools: true,
interactive: true,
onEvent: async (event) => {
await pushEvent(event)
},
})
if (currentChat && result.conversationId) {
await db
.update(copilotChats)
.set({
updatedAt: new Date(),
conversationId: result.conversationId,
})
.where(eq(copilotChats.id, actualChatId!))
}
await eventWriter.close()
await setStreamMeta(streamId, { status: 'complete', userId: authenticatedUserId })
} catch (error) {
logger.error(`[${tracker.requestId}] Orchestration error:`, error)
await eventWriter.close()
await setStreamMeta(streamId, {
status: 'error',
userId: authenticatedUserId,
error: error instanceof Error ? error.message : 'Stream error',
})
await pushEvent({
type: 'error',
data: {
displayMessage: 'An unexpected error occurred while processing the response.',
},
})
} finally {
try {
controller.close()
} catch {
// controller may already be closed by cancel()
}
}
},
async cancel() {
clientDisconnected = true
if (eventWriter) {
await eventWriter.close().catch(() => {})
}
const sseStream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
streamId: userMessageIdToUse,
chatId: actualChatId,
currentChat,
conversationHistory,
message,
titleModel: selectedModel,
titleProvider: provider,
requestId: tracker.requestId,
orchestrateOptions: {
userId: authenticatedUserId,
workflowId,
chatId: actualChatId,
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
},
})
return new Response(transformedStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
userId: authenticatedUserId,
workflowId,
chatId: actualChatId,
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
})
@@ -485,7 +351,7 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
requestChatTitle({ message, model: selectedModel, provider })
.then(async (title) => {
if (title) {
await db
@@ -509,9 +375,6 @@ export async function POST(req: NextRequest) {
.set({
messages: updatedMessages,
updatedAt: new Date(),
...(nonStreamingResult.conversationId
? { conversationId: nonStreamingResult.conversationId }
: {}),
})
.where(eq(copilotChats.id, actualChatId!))
}
@@ -563,16 +426,15 @@ export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const workflowId = searchParams.get('workflowId')
const workspaceId = searchParams.get('workspaceId')
const chatId = searchParams.get('chatId')
// Get authenticated user using consolidated helper
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
// If chatId is provided, fetch a single chat
if (chatId) {
const [chat] = await db
.select({
@@ -582,6 +444,7 @@ export async function GET(req: NextRequest) {
messages: copilotChats.messages,
planArtifact: copilotChats.planArtifact,
config: copilotChats.config,
conversationId: copilotChats.conversationId,
createdAt: copilotChats.createdAt,
updatedAt: copilotChats.updatedAt,
})
@@ -601,6 +464,7 @@ export async function GET(req: NextRequest) {
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
planArtifact: chat.planArtifact || null,
config: chat.config || null,
conversationId: chat.conversationId || null,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
}
@@ -609,11 +473,14 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: true, chat: transformedChat })
}
if (!workflowId) {
return createBadRequestResponse('workflowId or chatId is required')
if (!workflowId && !workspaceId) {
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
}
// Fetch chats for this user and workflow
const scopeFilter = workflowId
? eq(copilotChats.workflowId, workflowId)
: eq(copilotChats.workspaceId, workspaceId!)
const chats = await db
.select({
id: copilotChats.id,
@@ -626,12 +493,9 @@ export async function GET(req: NextRequest) {
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.where(
and(eq(copilotChats.userId, authenticatedUserId), eq(copilotChats.workflowId, workflowId))
)
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
.orderBy(desc(copilotChats.updatedAt))
// Transform the data to include message count
const transformedChats = chats.map((chat) => ({
id: chat.id,
title: chat.title,
@@ -644,7 +508,8 @@ export async function GET(req: NextRequest) {
updatedAt: chat.updatedAt,
}))
logger.info(`Retrieved ${transformedChats.length} chats for workflow ${workflowId}`)
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
logger.info(`Retrieved ${transformedChats.length} chats for ${scope}`)
return NextResponse.json({
success: true,

View File

@@ -4,7 +4,7 @@ import {
getStreamMeta,
readStreamEvents,
type StreamMeta,
} from '@/lib/copilot/orchestrator/stream-buffer'
} from '@/lib/copilot/orchestrator/stream/buffer'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { SSE_HEADERS } from '@/lib/core/utils/sse'

View File

@@ -20,7 +20,8 @@ const ConfirmationSchema = z.object({
status: z.enum(['success', 'error', 'accepted', 'rejected', 'background'] as const, {
errorMap: () => ({ message: 'Invalid notification status' }),
}),
message: z.string().optional(), // Optional message for background moves or additional context
message: z.string().optional(),
data: z.record(z.unknown()).optional(),
})
/**
@@ -30,7 +31,8 @@ const ConfirmationSchema = z.object({
async function updateToolCallStatus(
toolCallId: string,
status: NotificationStatus,
message?: string
message?: string,
data?: Record<string, unknown>
): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
@@ -40,11 +42,14 @@ async function updateToolCallStatus(
try {
const key = `${REDIS_TOOL_CALL_PREFIX}${toolCallId}`
const payload = {
const payload: Record<string, unknown> = {
status,
message: message || null,
timestamp: new Date().toISOString(),
}
if (data) {
payload.data = data
}
await redis.set(key, JSON.stringify(payload), 'EX', REDIS_TOOL_CALL_TTL_SECONDS)
return true
} catch (error) {
@@ -74,10 +79,10 @@ export async function POST(req: NextRequest) {
}
const body = await req.json()
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
const { toolCallId, status, message, data } = ConfirmationSchema.parse(body)
// Update the tool call status in Redis
const updated = await updateToolCallStatus(toolCallId, status, message)
const updated = await updateToolCallStatus(toolCallId, status, message, data)
if (!updated) {
logger.error(`[${tracker.requestId}] Failed to update tool call status`, {

View File

@@ -123,22 +123,24 @@ export async function POST(req: Request) {
)
}
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
// Check org existence and name uniqueness in parallel
const [orgExists, existingSet] = await Promise.all([
db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1),
db
.select({ id: credentialSet.id })
.from(credentialSet)
.where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name)))
.limit(1),
])
if (orgExists.length === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const existingSet = await db
.select({ id: credentialSet.id })
.from(credentialSet)
.where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name)))
.limit(1)
if (existingSet.length > 0) {
return NextResponse.json(
{ error: 'A credential set with this name already exists' },

View File

@@ -251,9 +251,18 @@ export async function POST(request: NextRequest) {
}
}
// Handle image-only contexts (copilot, chat, profile-pictures)
// Handle copilot, chat, profile-pictures contexts
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
if (!isImageFileType(file.type)) {
if (context === 'copilot') {
const { isSupportedFileType: isCopilotSupported } = await import(
'@/lib/uploads/contexts/copilot/copilot-file-manager'
)
if (!isImageFileType(file.type) && !isCopilotSupported(file.type)) {
throw new InvalidRequestError(
'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
)
}
} else if (!isImageFileType(file.type)) {
throw new InvalidRequestError(
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
)

View File

@@ -17,6 +17,7 @@ const DuplicateRequestSchema = z.object({
workspaceId: z.string().optional(),
parentId: z.string().nullable().optional(),
color: z.string().optional(),
newId: z.string().uuid().optional(),
})
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
@@ -33,7 +34,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
try {
const body = await req.json()
const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body)
const {
name,
workspaceId,
parentId,
color,
newId: clientNewId,
} = DuplicateRequestSchema.parse(body)
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
@@ -60,7 +67,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
const newFolderId = crypto.randomUUID()
const newFolderId = clientNewId || crypto.randomUUID()
const now = new Date()
const targetParentId = parentId ?? sourceFolder.parentId

View File

@@ -455,7 +455,7 @@ describe('Folders API Route', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error', 'Name and workspace ID are required')
expect(data).toHaveProperty('error', 'Invalid request data')
}
})

View File

@@ -3,12 +3,22 @@ import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, isNull, min } 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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersAPI')
const CreateFolderSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Name is required'),
workspaceId: z.string().min(1, 'Workspace ID is required'),
parentId: z.string().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
})
// GET - Fetch folders for a workspace
export async function GET(request: NextRequest) {
try {
@@ -59,13 +69,15 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
const {
id: clientId,
name,
workspaceId,
parentId,
color,
sortOrder: providedSortOrder,
} = CreateFolderSchema.parse(body)
if (!name || !workspaceId) {
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
}
// Check if user has workspace permissions (at least 'write' access to create folders)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -79,8 +91,7 @@ export async function POST(request: NextRequest) {
)
}
// Generate a new ID
const id = crypto.randomUUID()
const id = clientId || crypto.randomUUID()
const newFolder = await db.transaction(async (tx) => {
let sortOrder: number
@@ -150,6 +161,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ folder: newFolder })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn('Invalid folder creation data', { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error('Error creating folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -118,21 +118,16 @@ export async function POST(request: NextRequest) {
)
}
const existingIdentifier = await db
.select()
.from(form)
.where(eq(form.identifier, identifier))
.limit(1)
// Check identifier availability and workflow access in parallel
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
db.select().from(form).where(eq(form.identifier, identifier)).limit(1),
checkWorkflowAccessForFormCreation(workflowId, session.user.id),
])
if (existingIdentifier.length > 0) {
return createErrorResponse('Identifier already in use', 400)
}
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForFormCreation(
workflowId,
session.user.id
)
if (!hasAccess || !workflowRecord) {
return createErrorResponse('Workflow not found or access denied', 404)
}

View File

@@ -0,0 +1,202 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckSession, mockCheckAccess, mockCheckWriteAccess, mockDbChain } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
return {
mockCheckSession: vi.fn(),
mockCheckAccess: vi.fn(),
mockCheckWriteAccess: vi.fn(),
mockDbChain: chain,
}
})
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@sim/db/schema', () => ({
document: {
id: 'id',
connectorId: 'connectorId',
deletedAt: 'deletedAt',
filename: 'filename',
externalId: 'externalId',
sourceUrl: 'sourceUrl',
enabled: 'enabled',
userExcluded: 'userExcluded',
uploadedAt: 'uploadedAt',
processingStatus: 'processingStatus',
},
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
},
}))
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: mockCheckAccess,
checkKnowledgeBaseWriteAccess: mockCheckWriteAccess,
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSession,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
import { GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
describe('Connector Documents API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockResolvedValue([])
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
mockCheckSession.mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('GET')
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns documents list on success', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckAccess.mockResolvedValue({ hasAccess: true })
const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false }
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy.mockResolvedValueOnce([doc])
const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents'
const req = createMockRequest('GET', undefined, undefined, url)
Object.assign(req, { nextUrl: new URL(url) })
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(1)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(0)
})
it('includes excluded documents when includeExcluded=true', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy
.mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }])
.mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }])
const url =
'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true'
const req = createMockRequest('GET', undefined, undefined, url)
Object.assign(req, { nextUrl: new URL(url) })
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(2)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
mockCheckSession.mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('PATCH', { documentIds: [] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(400)
})
it('returns 404 when connector not found', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns success for restore operation', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.restoredCount).toBe(1)
})
it('returns success for exclude operation', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
const req = createMockRequest('PATCH', {
operation: 'exclude',
documentIds: ['doc-2', 'doc-3'],
})
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.excludedCount).toBe(2)
expect(data.data.documentIds).toEqual(['doc-2', 'doc-3'])
})
})
})

View File

@@ -0,0 +1,211 @@
import { db } from '@sim/db'
import { document, knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorDocumentsAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* GET /api/knowledge/[id]/connectors/[connectorId]/documents
* Returns documents for a connector, optionally including user-excluded ones.
*/
export async function GET(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const includeExcluded = request.nextUrl.searchParams.get('includeExcluded') === 'true'
const activeDocs = await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
isNull(document.deletedAt),
eq(document.userExcluded, false)
)
)
.orderBy(document.filename)
const excludedDocs = includeExcluded
? await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
eq(document.userExcluded, true),
isNull(document.deletedAt)
)
)
.orderBy(document.filename)
: []
const docs = [...activeDocs, ...excludedDocs]
const activeCount = activeDocs.length
const excludedCount = excludedDocs.length
return NextResponse.json({
success: true,
data: {
documents: docs,
counts: { active: activeCount, excluded: excludedCount },
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const PatchSchema = z.object({
operation: z.enum(['restore', 'exclude']),
documentIds: z.array(z.string()).min(1),
})
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId]/documents
* Restore or exclude connector documents.
*/
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const body = await request.json()
const parsed = PatchSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { operation, documentIds } = parsed.data
if (operation === 'restore') {
const updated = await db
.update(document)
.set({ userExcluded: false, deletedAt: null, enabled: true })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, true),
isNull(document.deletedAt)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId })
return NextResponse.json({
success: true,
data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) },
})
}
const updated = await db
.update(document)
.set({ userExcluded: true })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, false),
isNull(document.deletedAt)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId })
return NextResponse.json({
success: true,
data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) },
})
} catch (error) {
logger.error(`[${requestId}] Error updating connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,205 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckSession, mockCheckAccess, mockCheckWriteAccess, mockDbChain, mockValidateConfig } =
vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
return {
mockCheckSession: vi.fn(),
mockCheckAccess: vi.fn(),
mockCheckWriteAccess: vi.fn(),
mockDbChain: chain,
mockValidateConfig: vi.fn(),
}
})
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@sim/db/schema', () => ({
document: { connectorId: 'connectorId', deletedAt: 'deletedAt' },
knowledgeBase: { id: 'id', userId: 'userId' },
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
connectorType: 'connectorType',
credentialId: 'credentialId',
},
knowledgeConnectorSyncLog: { connectorId: 'connectorId', startedAt: 'startedAt' },
}))
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: mockCheckAccess,
checkKnowledgeBaseWriteAccess: mockCheckWriteAccess,
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSession,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
vi.mock('@/app/api/auth/oauth/utils', () => ({
refreshAccessTokenIfNeeded: vi.fn(),
}))
vi.mock('@/connectors/registry', () => ({
CONNECTOR_REGISTRY: {
jira: { validateConfig: mockValidateConfig },
},
}))
vi.mock('@/lib/knowledge/tags/service', () => ({
cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined),
}))
import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route'
describe('Knowledge Connector By ID API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockReturnThis()
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
mockCheckSession.mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when KB not found', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckAccess.mockResolvedValue({ hasAccess: false, notFound: true })
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 404 when connector not found', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns connector with sync logs on success', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckAccess.mockResolvedValue({ hasAccess: true })
const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' }
const mockLogs = [{ id: 'log-1', status: 'completed' }]
mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.id).toBe('conn-456')
expect(data.data.syncLogs).toHaveLength(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
mockCheckSession.mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('PATCH', { status: 'paused' })
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request')
})
it('returns 404 when connector not found during sourceConfig validation', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 200 and updates status', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.status).toBe('paused')
})
})
describe('DELETE', () => {
it('returns 401 when unauthenticated', async () => {
mockCheckSession.mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 200 on successful soft-delete', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
})
})

View File

@@ -0,0 +1,287 @@
import { db } from '@sim/db'
import {
document,
knowledgeBase,
knowledgeConnector,
knowledgeConnectorSyncLog,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { decryptApiKey } from '@/lib/api-key/crypto'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
const logger = createLogger('KnowledgeConnectorByIdAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
const UpdateConnectorSchema = z.object({
sourceConfig: z.record(z.unknown()).optional(),
syncIntervalMinutes: z.number().int().min(0).optional(),
status: z.enum(['active', 'paused']).optional(),
})
/**
* GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs
*/
export async function GET(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const syncLogs = await db
.select()
.from(knowledgeConnectorSyncLog)
.where(eq(knowledgeConnectorSyncLog.connectorId, connectorId))
.orderBy(desc(knowledgeConnectorSyncLog.startedAt))
.limit(10)
const { encryptedApiKey: _, ...connectorData } = connectorRows[0]
return NextResponse.json({
success: true,
data: {
...connectorData,
syncLogs,
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
*/
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const body = await request.json()
const parsed = UpdateConnectorSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
if (parsed.data.sourceConfig !== undefined) {
const existingRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const existing = existingRows[0]
const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${existing.connectorType}` },
{ status: 400 }
)
}
const kbRows = await db
.select({ userId: knowledgeBase.userId })
.from(knowledgeBase)
.where(eq(knowledgeBase.id, knowledgeBaseId))
.limit(1)
if (kbRows.length === 0) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
let accessToken: string | null = null
if (connectorConfig.auth.mode === 'apiKey') {
if (!existing.encryptedApiKey) {
return NextResponse.json(
{ error: 'API key not found. Please reconfigure the connector.' },
{ status: 400 }
)
}
accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted
} else {
if (!existing.credentialId) {
return NextResponse.json(
{ error: 'OAuth credential not found. Please reconfigure the connector.' },
{ status: 400 }
)
}
accessToken = await refreshAccessTokenIfNeeded(
existing.credentialId,
kbRows[0].userId,
`patch-${connectorId}`
)
}
if (!accessToken) {
return NextResponse.json(
{ error: 'Failed to refresh access token. Please reconnect your account.' },
{ status: 401 }
)
}
const validation = await connectorConfig.validateConfig(accessToken, parsed.data.sourceConfig)
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (parsed.data.sourceConfig !== undefined) {
updates.sourceConfig = parsed.data.sourceConfig
}
if (parsed.data.syncIntervalMinutes !== undefined) {
updates.syncIntervalMinutes = parsed.data.syncIntervalMinutes
if (parsed.data.syncIntervalMinutes > 0) {
updates.nextSyncAt = new Date(Date.now() + parsed.data.syncIntervalMinutes * 60 * 1000)
} else {
updates.nextSyncAt = null
}
}
if (parsed.data.status !== undefined) {
updates.status = parsed.data.status
}
await db
.update(knowledgeConnector)
.set(updates)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
const updated = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
const { encryptedApiKey: __, ...updatedData } = updated[0]
return NextResponse.json({ success: true, data: updatedData })
} catch (error) {
logger.error(`[${requestId}] Error updating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/knowledge/[id]/connectors/[connectorId] - Soft-delete a connector
*/
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const now = new Date()
await db
.update(knowledgeConnector)
.set({ deletedAt: now, status: 'paused', updatedAt: now })
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
// Soft-delete all documents belonging to this connector
await db
.update(document)
.set({ deletedAt: now })
.where(and(eq(document.connectorId, connectorId), isNull(document.deletedAt)))
// Reclaim tag slots that are no longer used by any active connector
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
})
logger.info(`[${requestId}] Soft-deleted connector ${connectorId} and its documents`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error deleting connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,107 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckSession, mockCheckWriteAccess, mockDispatchSync, mockDbChain } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
}
return {
mockCheckSession: vi.fn(),
mockCheckWriteAccess: vi.fn(),
mockDispatchSync: vi.fn().mockResolvedValue(undefined),
mockDbChain: chain,
}
})
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@sim/db/schema', () => ({
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
status: 'status',
},
}))
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseWriteAccess: mockCheckWriteAccess,
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSession,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
dispatchSync: mockDispatchSync,
}))
import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route'
describe('Connector Manual Sync API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockResolvedValue([])
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
})
it('returns 401 when unauthenticated', async () => {
mockCheckSession.mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 409 when connector is syncing', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(409)
})
it('dispatches sync on valid request', async () => {
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
})
})

View File

@@ -0,0 +1,71 @@
import { db } from '@sim/db'
import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorManualSyncAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* POST /api/knowledge/[id]/connectors/[connectorId]/sync - Trigger a manual sync
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
if (connectorRows[0].status === 'syncing') {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 })
}
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
dispatchSync(connectorId, { requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
error
)
})
return NextResponse.json({
success: true,
message: 'Sync triggered',
})
} catch (error) {
logger.error(`[${requestId}] Error triggering manual sync`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,235 @@
import { db } from '@sim/db'
import { knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { encryptApiKey } from '@/lib/api-key/crypto'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { allocateTagSlots } from '@/lib/knowledge/constants'
import { createTagDefinition } from '@/lib/knowledge/tags/service'
import { getCredential } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
const logger = createLogger('KnowledgeConnectorsAPI')
const CreateConnectorSchema = z.object({
connectorType: z.string().min(1),
credentialId: z.string().min(1).optional(),
apiKey: z.string().min(1).optional(),
sourceConfig: z.record(z.unknown()),
syncIntervalMinutes: z.number().int().min(0).default(1440),
})
/**
* GET /api/knowledge/[id]/connectors - List connectors for a knowledge base
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id: knowledgeBaseId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectors = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.orderBy(desc(knowledgeConnector.createdAt))
return NextResponse.json({
success: true,
data: connectors.map(({ encryptedApiKey: _, ...rest }) => rest),
})
} catch (error) {
logger.error(`[${requestId}] Error listing connectors`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/knowledge/[id]/connectors - Create a new connector
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id: knowledgeBaseId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const body = await request.json()
const parsed = CreateConnectorSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${connectorType}` },
{ status: 400 }
)
}
let resolvedCredentialId: string | null = null
let resolvedEncryptedApiKey: string | null = null
let accessToken: string
if (connectorConfig.auth.mode === 'apiKey') {
if (!apiKey) {
return NextResponse.json({ error: 'API key is required' }, { status: 400 })
}
accessToken = apiKey
} else {
if (!credentialId) {
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
}
if (!credential.accessToken) {
return NextResponse.json(
{ error: 'Credential has no access token. Please reconnect your account.' },
{ status: 400 }
)
}
accessToken = credential.accessToken
resolvedCredentialId = credentialId
}
const validation = await connectorConfig.validateConfig(accessToken, sourceConfig)
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
let finalSourceConfig: Record<string, unknown> = { ...sourceConfig }
if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
const { encrypted } = await encryptApiKey(apiKey)
resolvedEncryptedApiKey = encrypted
}
const tagSlotMapping: Record<string, string> = {}
if (connectorConfig.tagDefinitions?.length) {
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
const existingDefs = await db
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
.from(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
Object.assign(tagSlotMapping, mapping)
for (const name of skippedTags) {
logger.warn(`[${requestId}] No available slots for "${name}"`)
}
if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
return NextResponse.json(
{ error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` },
{ status: 422 }
)
}
finalSourceConfig = { ...finalSourceConfig, tagSlotMapping }
}
const now = new Date()
const connectorId = crypto.randomUUID()
const nextSyncAt =
syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null
await db.transaction(async (tx) => {
for (const [semanticId, slot] of Object.entries(tagSlotMapping)) {
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
await createTagDefinition(
{
knowledgeBaseId,
tagSlot: slot,
displayName: td.displayName,
fieldType: td.fieldType,
},
requestId,
tx
)
}
await tx.insert(knowledgeConnector).values({
id: connectorId,
knowledgeBaseId,
connectorType,
credentialId: resolvedCredentialId,
encryptedApiKey: resolvedEncryptedApiKey,
sourceConfig: finalSourceConfig,
syncIntervalMinutes,
status: 'active',
nextSyncAt,
createdAt: now,
updatedAt: now,
})
})
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
dispatchSync(connectorId, { requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
error
)
})
const created = await db
.select()
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connectorId))
.limit(1)
const { encryptedApiKey: _, ...createdData } = created[0]
return NextResponse.json({ success: true, data: createdData }, { status: 201 })
} catch (error) {
logger.error(`[${requestId}] Error creating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

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