Compare commits

...

89 Commits

Author SHA1 Message Date
Waleed Latif
3c3c5b5b1c 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>
2026-03-05 13:56:01 -08:00
Waleed Latif
ea6692517b 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>
2026-03-05 13:26:56 -08:00
Waleed Latif
9e0c41df8c Merge remote-tracking branch 'origin/feat/mothership-copilot' into waleedlatif1/resolve-kb-conflicts
# Conflicts:
#	apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
#	apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
#	apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx
#	apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx
#	apps/sim/blocks/blocks/airtable.ts
#	apps/sim/blocks/blocks/knowledge.ts
#	apps/sim/tools/airtable/list_bases.ts
#	apps/sim/tools/registry.ts
#	packages/db/migrations/meta/0155_snapshot.json
#	packages/db/migrations/meta/_journal.json
#	packages/db/schema.ts
2026-03-05 13:25:24 -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
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
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
Siddharth Ganesan
b2cc5b6738 Billing 2026-02-28 17:51:26 -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
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
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
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
Siddharth Ganesan
632e0e0762 Checkpoitn 2026-02-18 15:29:58 -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
4453edbaa8 ack comments 2026-02-17 11:45:38 -08:00
waleed
f0c3392e3f ack comments 2026-02-17 11:28:40 -08:00
waleed
74db11c0fe added tests 2026-02-17 10:51:06 -08:00
waleed
e207ad2502 ack PR comment 2026-02-17 09:51:13 -08:00
waleed
f5640b2919 regen migrations, ack PR comments 2026-02-16 23:51:33 -08:00
waleed
b0d9f4e8b5 remove module level cache, use syncContext between paginated calls to avoid redundant schema fetches 2026-02-16 23:33:14 -08:00
waleed
e7c143bdcc ack PR comments 2026-02-16 23:30:38 -08:00
waleed
6fcc820914 removed redundant util 2026-02-16 23:05:01 -08:00
waleed
82b874c027 improvements 2026-02-16 22:43:12 -08:00
waleed
3eaf5babf4 feat(knowledge): connectors, user exclusions, expanded tools & airtable integration 2026-02-16 22:12:10 -08:00
432 changed files with 55859 additions and 6371 deletions

View File

@@ -0,0 +1,261 @@
---
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. Create the connector directory and config
3. 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
```
## ConnectorConfig Structure
```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,
oauth: {
required: true,
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
},
}
```
## 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
You never need to modify the sync engine when adding a connector.
## OAuth Credential Reuse
Connectors reuse the existing OAuth infrastructure. The `oauth.provider` must match an `OAuthService` from `apps/sim/lib/oauth/types.ts`. Check existing providers before adding a new one.
## 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 Implementation
See `apps/sim/connectors/confluence/confluence.ts` for a complete example with:
- Multiple config field types (text + dropdown)
- Label fetching and CQL search filtering
- Blogpost + page content types
- `mapTags` mapping labels, version, and dates to semantic keys
## Checklist
- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig
- [ ] Created `connectors/{service}/index.ts` barrel export
- [ ] `oauth.provider` matches an existing OAuthService in `lib/oauth/types.ts`
- [ ] `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.

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

@@ -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

@@ -367,15 +367,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 +387,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 +409,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

@@ -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,8 @@
"(generated)/workflows",
"(generated)/logs",
"(generated)/usage",
"(generated)/audit-logs"
"(generated)/audit-logs",
"(generated)/tables",
"(generated)/files"
]
}

View File

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

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 |

File diff suppressed because it is too large Load Diff

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,226 @@
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 }>
}
/**
* 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,218 @@
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: [
'$20 usage limit',
'5GB file storage',
'5 min execution limit',
'Limited log retention',
'CLI/SDK Access',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'professional',
name: 'Professional',
description: 'For professionals building production workflows',
price: '$20',
billingPeriod: 'per month',
color: '#00F701',
features: [
'150 runs per minute (sync)',
'1,000 runs per minute (async)',
'50 min sync execution limit',
'50GB file storage',
'Unlimited invites',
'Unlimited log retention',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'team',
name: 'Team',
description: 'For teams collaborating on complex agents',
price: '$40',
billingPeriod: 'per month',
color: '#FA4EDF',
features: [
'300 runs per minute (sync)',
'2,500 runs per minute (async)',
'500GB file storage (pooled)',
'50 min sync execution limit',
'Unlimited invites',
'Unlimited log retention',
'Dedicated Slack channel',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'For organizations needing security and scale',
price: 'Custom',
color: '#FFCC02',
features: ['Custom rate limits', 'Custom file storage', 'SSO', 'SOC2', '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 isProfessional = tier.id === 'professional'
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>
) : isProfessional ? (
<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.
*
* SEO:
* - `<section id="pricing" aria-labelledby="pricing-heading">`.
* - `<h2 id="pricing-heading">` for the section title.
* - Each tier: `<h3>` plan name + semantic `<ul>` feature list.
* - Free tier CTA uses `<Link href="/signup">` (crawlable). Enterprise CTA uses `<a>`.
*
* GEO:
* - Each plan has consistent structure: name, price, billing period, feature list.
* - Lead with a summary: "Sim offers a free Community plan, $20/mo Pro, $40/mo Team, custom Enterprise."
* - Prices must match the `Offer` items in structured-data.tsx exactly.
*/
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',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Pro Plan',
price: '20',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '20',
priceCurrency: 'USD',
unitText: 'MONTH',
billingIncrement: 1,
},
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Team Plan',
price: '40',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '40',
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 $20 usage limit, a Pro plan at $20/month, a Team plan at $40/month, 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,582 @@
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',
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',
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',
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',
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',
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',
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',
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',
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',
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',
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',
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,549 @@
'use client'
import { useRef, useState } from 'react'
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
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 { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start 0.9', 'start 0.2'],
})
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
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>
<Link
href='/signup'
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] 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'
>
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>
</Link>
</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

@@ -20,7 +20,7 @@ import {
ENTERPRISE_PLAN_FEATURES,
PRO_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
} from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
const logger = createLogger('LandingPricing')

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

@@ -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,23 +86,24 @@
: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 */
--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: #eeeeee; /* 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-inverse: #ffffff;
--text-muted-inverse: #a0a0a0;
@@ -125,7 +126,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 */
@@ -211,7 +212,8 @@
--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 */
@@ -241,7 +243,7 @@
--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

@@ -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

@@ -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

@@ -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,7 +71,6 @@ 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(),
@@ -155,7 +109,6 @@ export async function POST(req: NextRequest) {
implicitFeedback,
fileAttachments,
provider,
conversationId,
contexts,
commands,
} = ChatMessageSchema.parse(body)
@@ -174,7 +127,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 +139,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 +168,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 +198,6 @@ export async function POST(req: NextRequest) {
}
}
// Handle chat context
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
@@ -249,27 +212,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 +243,7 @@ export async function POST(req: NextRequest) {
chatId: actualChatId,
prefetch,
implicitFeedback,
userPermission: userPermission ?? undefined,
},
{
selectedModel,
@@ -287,7 +254,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 +268,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 +348,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 +372,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 +423,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 +441,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 +461,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 +470,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 +490,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 +505,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

@@ -0,0 +1,300 @@
/**
* @vitest-environment node
*/
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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: vi.fn(),
checkKnowledgeBaseWriteAccess: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
mockDrizzleOrm()
mockConsoleLogger()
describe('Connector Documents API Route', () => {
/**
* The route chains db calls in sequence. We track call order
* to return different values for connector lookup vs document queries.
*/
let limitCallCount: number
let orderByCallCount: number
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn(() => {
orderByCallCount++
return Promise.resolve([])
}),
limit: vi.fn(() => {
limitCallCount++
return Promise.resolve([])
}),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
limitCallCount = 0
orderByCallCount = 0
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockImplementation(() => {
orderByCallCount++
return Promise.resolve([])
})
mockDbChain.limit.mockImplementation(() => {
limitCallCount++
return Promise.resolve([])
})
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: false,
userId: null,
} as never)
const req = createMockRequest('GET')
const { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns documents list on success', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
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 { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
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 () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
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 { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
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 () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: false,
userId: null,
} as never)
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('PATCH', { documentIds: [] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(400)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns success for restore operation', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
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 () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
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 { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
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,210 @@
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)
)
)
.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,231 @@
/**
* @vitest-environment node
*/
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: vi.fn(),
checkKnowledgeBaseWriteAccess: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn(),
}))
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: vi.fn() },
},
}))
vi.mock('@sim/db/schema', () => ({
knowledgeBase: { id: 'id', userId: 'userId' },
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
connectorType: 'connectorType',
credentialId: 'credentialId',
},
knowledgeConnectorSyncLog: { connectorId: 'connectorId', startedAt: 'startedAt' },
}))
mockDrizzleOrm()
mockConsoleLogger()
describe('Knowledge Connector By ID API Route', () => {
const mockDbChain = {
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([]),
}
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockReturnThis()
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('GET')
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when KB not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false, notFound: true })
const req = createMockRequest('GET')
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns connector with sync logs on success', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseAccess).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 { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
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 () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('PATCH', { status: 'paused' })
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
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 () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 200 and updates status', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).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 { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
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 () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('DELETE')
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await DELETE(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 200 on successful soft-delete', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
const req = createMockRequest('DELETE')
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
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,265 @@
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 { 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)
return NextResponse.json({
success: true,
data: {
...connectorRows[0],
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 })
}
const 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)
return NextResponse.json({ success: true, data: updated[0] })
} 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 })
}
await db
.update(knowledgeConnector)
.set({ deletedAt: new Date(), status: 'paused', updatedAt: new Date() })
.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: new Date() })
.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,133 @@
/**
* @vitest-environment node
*/
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db/schema', () => ({
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
status: 'status',
},
}))
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseWriteAccess: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
dispatchSync: vi.fn().mockResolvedValue(undefined),
}))
mockDrizzleOrm()
mockConsoleLogger()
describe('Connector Manual Sync API Route', () => {
const mockDbChain = {
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(),
}
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()
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
})
afterEach(() => {
vi.clearAllMocks()
})
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: false,
userId: null,
} as never)
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 409 when connector is syncing', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(409)
})
it('dispatches sync on valid request', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
const { dispatchSync } = await import('@/lib/knowledge/connectors/sync-engine')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
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(vi.mocked(dispatchSync)).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,204 @@
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 { 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),
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 })
} 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, sourceConfig, syncIntervalMinutes } = parsed.data
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${connectorType}` },
{ 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 }
)
}
const validation = await connectorConfig.validateConfig(credential.accessToken, sourceConfig)
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
let finalSourceConfig: Record<string, unknown> = sourceConfig
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 = { ...sourceConfig, 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,
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)
return NextResponse.json({ success: true, data: created[0] }, { status: 201 })
} catch (error) {
logger.error(`[${requestId}] Error creating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -13,6 +13,7 @@ import {
getDocuments,
getProcessingConfig,
processDocumentsWithQueue,
type TagFilterCondition,
} from '@/lib/knowledge/documents/service'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -131,6 +132,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
? (sortOrderParam as SortOrder)
: undefined
let tagFilters: TagFilterCondition[] | undefined
const tagFiltersParam = url.searchParams.get('tagFilters')
if (tagFiltersParam) {
try {
const parsed = JSON.parse(tagFiltersParam)
if (Array.isArray(parsed)) {
tagFilters = parsed.filter(
(f: TagFilterCondition) => f.tagSlot && f.operator && f.value !== undefined
)
}
} catch {
logger.warn(`[${requestId}] Invalid tagFilters param`)
}
}
const result = await getDocuments(
knowledgeBaseId,
{
@@ -140,6 +156,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
offset,
...(sortBy && { sortBy }),
...(sortOrder && { sortOrder }),
tagFilters,
},
requestId
)

View File

@@ -0,0 +1,68 @@
import { db } from '@sim/db'
import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, inArray, isNull, lte } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { generateRequestId } from '@/lib/core/utils/request'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
export const dynamic = 'force-dynamic'
const logger = createLogger('ConnectorSyncSchedulerAPI')
/**
* Cron endpoint that checks for connectors due for sync and dispatches sync jobs.
* Should be called every 5 minutes by an external cron service.
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
logger.info(`[${requestId}] Connector sync scheduler triggered`)
const authError = verifyCronAuth(request, 'Connector sync scheduler')
if (authError) {
return authError
}
try {
const now = new Date()
const dueConnectors = await db
.select({
id: knowledgeConnector.id,
})
.from(knowledgeConnector)
.where(
and(
inArray(knowledgeConnector.status, ['active', 'error']),
lte(knowledgeConnector.nextSyncAt, now),
isNull(knowledgeConnector.deletedAt)
)
)
logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for sync`)
if (dueConnectors.length === 0) {
return NextResponse.json({
success: true,
message: 'No connectors due for sync',
count: 0,
})
}
for (const connector of dueConnectors) {
dispatchSync(connector.id, { requestId }).catch((error) => {
logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error)
})
}
return NextResponse.json({
success: true,
message: `Dispatched ${dueConnectors.length} connector sync(s)`,
count: dueConnectors.length,
})
} catch (error) {
logger.error(`[${requestId}] Connector sync scheduler error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -18,11 +18,7 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
ORCHESTRATION_TIMEOUT_MS,
SIM_AGENT_API_URL,
SIM_AGENT_VERSION,
} from '@/lib/copilot/constants'
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
import {
@@ -724,16 +720,14 @@ async function handleBuildToolCall(
mode: 'agent',
commands: ['fast'],
messageId: randomUUID(),
version: SIM_AGENT_VERSION,
headless: true,
chatId,
source: 'mcp',
}
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workflowId: resolved.workflowId,
chatId,
goRoute: '/api/mcp',
autoExecuteTools: true,
timeout: 300000,
interactive: false,

View File

@@ -0,0 +1,229 @@
import { db } from '@sim/db'
import { copilotChats } 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 { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MothershipChatAPI')
const FileAttachmentSchema = z.object({
id: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
const MothershipMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
workspaceId: z.string().min(1, 'workspaceId is required'),
userMessageId: z.string().optional(),
chatId: z.string().optional(),
createNewChat: z.boolean().optional().default(false),
fileAttachments: z.array(FileAttachmentSchema).optional(),
contexts: z
.array(
z.object({
kind: z.enum([
'past_chat',
'workflow',
'current_workflow',
'blocks',
'logs',
'workflow_block',
'knowledge',
'templates',
'docs',
]),
label: z.string(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
knowledgeId: z.string().optional(),
blockId: z.string().optional(),
blockIds: z.array(z.string()).optional(),
templateId: z.string().optional(),
executionId: z.string().optional(),
})
)
.optional(),
})
/**
* POST /api/mothership/chat
* Workspace-scoped chat — no workflowId, proxies to Go /api/mothership.
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {
const session = await getSession()
if (!session?.user?.id) {
return createUnauthorizedResponse()
}
const authenticatedUserId = session.user.id
const body = await req.json()
const {
message,
workspaceId,
userMessageId: providedMessageId,
chatId,
createNewChat,
fileAttachments,
contexts,
} = MothershipMessageSchema.parse(body)
const userMessageId = providedMessageId || crypto.randomUUID()
let agentContexts: Array<{ type: string; content: string }> = []
if (Array.isArray(contexts) && contexts.length > 0) {
try {
const { processContextsServer } = await import('@/lib/copilot/process-contents')
agentContexts = await processContextsServer(contexts as any, authenticatedUserId, message)
} catch (e) {
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
}
}
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
if (chatId || createNewChat) {
const chatResult = await resolveOrCreateChat({
chatId,
userId: authenticatedUserId,
workspaceId,
model: 'claude-opus-4-5',
type: 'mothership',
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId
conversationHistory = Array.isArray(chatResult.conversationHistory)
? chatResult.conversationHistory
: []
}
if (actualChatId) {
const userMsg = {
id: userMessageId,
role: 'user' as const,
content: message,
timestamp: new Date().toISOString(),
}
await db
.update(copilotChats)
.set({
messages: [...conversationHistory, userMsg],
conversationId: userMessageId,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId))
}
const [workspaceContext, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, authenticatedUserId),
getUserEntityPermissions(authenticatedUserId, 'workspace', workspaceId).catch(() => null),
])
const requestPayload = await buildCopilotRequestPayload(
{
message,
workspaceId,
userId: authenticatedUserId,
userMessageId,
mode: 'agent',
model: '',
conversationHistory,
contexts: agentContexts,
fileAttachments,
chatId: actualChatId,
userPermission: userPermission ?? undefined,
workspaceContext,
},
{ selectedModel: '' }
)
const stream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
streamId: userMessageId,
chatId: actualChatId,
currentChat,
conversationHistory,
message,
titleModel: 'claude-opus-4-5',
requestId: tracker.requestId,
orchestrateOptions: {
userId: authenticatedUserId,
workspaceId,
chatId: actualChatId,
goRoute: '/api/mothership',
autoExecuteTools: true,
interactive: false,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
const userMessage = {
id: userMessageId,
role: 'user' as const,
content: message,
timestamp: new Date().toISOString(),
}
const assistantMessage = {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: result.content,
timestamp: new Date().toISOString(),
}
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
try {
await db
.update(copilotChats)
.set({
messages: updatedMessages,
conversationId: null,
})
.where(eq(copilotChats.id, actualChatId))
} catch (error) {
logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
},
},
})
return new Response(stream, { headers: SSE_RESPONSE_HEADERS })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${tracker.requestId}] Error handling mothership chat:`, {
error: error instanceof Error ? error.message : 'Unknown error',
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,93 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
const logger = createLogger('MothershipChatsAPI')
/**
* GET /api/mothership/chats?workspaceId=xxx
* Returns mothership (home) chats for the authenticated user in the given workspace.
*/
export async function GET(request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
if (!workspaceId) {
return createBadRequestResponse('workspaceId is required')
}
const chats = await db
.select({
id: copilotChats.id,
title: copilotChats.title,
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.where(
and(
eq(copilotChats.userId, userId),
eq(copilotChats.workspaceId, workspaceId),
eq(copilotChats.type, 'mothership')
)
)
.orderBy(desc(copilotChats.updatedAt))
return NextResponse.json({ success: true, data: chats })
} catch (error) {
logger.error('Error fetching mothership chats:', error)
return createInternalServerErrorResponse('Failed to fetch chats')
}
}
const CreateChatSchema = z.object({
workspaceId: z.string().min(1),
})
/**
* POST /api/mothership/chats
* Creates an empty mothership chat and returns its ID.
*/
export async function POST(request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const body = await request.json()
const { workspaceId } = CreateChatSchema.parse(body)
const [chat] = await db
.insert(copilotChats)
.values({
userId,
workspaceId,
type: 'mothership',
title: null,
model: 'claude-opus-4-5',
messages: [],
})
.returning({ id: copilotChats.id })
return NextResponse.json({ success: true, id: chat.id })
} catch (error) {
if (error instanceof z.ZodError) {
return createBadRequestResponse('workspaceId is required')
}
logger.error('Error creating mothership chat:', error)
return createInternalServerErrorResponse('Failed to create chat')
}
}

View File

@@ -0,0 +1,109 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MothershipExecuteAPI')
const MessageSchema = z.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
})
const ExecuteRequestSchema = z.object({
messages: z.array(MessageSchema).min(1, 'At least one message is required'),
responseFormat: z.any().optional(),
workspaceId: z.string().min(1, 'workspaceId is required'),
userId: z.string().min(1, 'userId is required'),
chatId: z.string().optional(),
})
/**
* POST /api/mothership/execute
*
* Non-streaming endpoint for Mothership block execution within workflows.
* Called by the executor via internal JWT auth, not by the browser directly.
* Consumes the Go SSE stream internally and returns a single JSON response.
*/
export async function POST(req: NextRequest) {
try {
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { messages, responseFormat, workspaceId, userId, chatId } =
ExecuteRequestSchema.parse(body)
const effectiveChatId = chatId || crypto.randomUUID()
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
])
const requestPayload: Record<string, unknown> = {
messages,
responseFormat,
userId,
chatId: effectiveChatId,
mode: 'agent',
messageId: crypto.randomUUID(),
isHosted: true,
workspaceContext,
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(userPermission ? { userPermission } : {}),
}
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workspaceId,
chatId: effectiveChatId,
goRoute: '/api/mothership/execute',
autoExecuteTools: true,
interactive: false,
})
if (!result.success) {
logger.error('Mothership execute failed', {
error: result.error,
errors: result.errors,
})
return NextResponse.json(
{
error: result.error || 'Mothership execution failed',
content: result.content || '',
},
{ status: 500 }
)
}
return NextResponse.json({
content: result.content,
model: 'mothership',
tokens: {},
toolCalls: result.toolCalls,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error('Mothership execute error', {
error: error instanceof Error ? error.message : 'Unknown error',
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -1,12 +1,9 @@
import { db } from '@sim/db'
import { skill } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } 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 { upsertSkills } from '@/lib/workflows/skills/operations'
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('SkillsAPI')
@@ -53,11 +50,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const result = await db
.select()
.from(skill)
.where(eq(skill.workspaceId, workspaceId))
.orderBy(desc(skill.createdAt))
const result = await listSkills({ workspaceId })
return NextResponse.json({ data: result }, { status: 200 })
} catch (error) {
@@ -159,20 +152,12 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
const existingSkill = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1)
if (existingSkill.length === 0) {
const deleted = await deleteSkill({ skillId, workspaceId })
if (!deleted) {
logger.warn(`[${requestId}] Skill not found: ${skillId}`)
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
}
if (existingSkill[0].workspaceId !== workspaceId) {
logger.warn(`[${requestId}] Skill ${skillId} does not belong to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
}
await db.delete(skill).where(and(eq(skill.id, skillId), eq(skill.workspaceId, workspaceId)))
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -4,7 +4,7 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteTable, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
const logger = createLogger('TableDetailAPI')
@@ -38,11 +38,7 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
@@ -108,11 +104,7 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

View File

@@ -6,9 +6,9 @@ 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 type { RowData, TableSchema } from '@/lib/table'
import { validateRowData } from '@/lib/table'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
import type { RowData } from '@/lib/table'
import { updateRow } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableRowAPI')
@@ -50,11 +50,7 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
@@ -87,8 +83,10 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
row: {
id: row.id,
data: row.data,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
updatedAt:
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
},
},
})
@@ -116,7 +114,13 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = UpdateRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
@@ -124,15 +128,10 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// Fetch existing row to support partial updates
const [existingRow] = await db
.select({ data: userTableRows.data })
.from(userTableRows)
@@ -149,42 +148,21 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
// Merge existing data with incoming partial data (incoming takes precedence)
const mergedData = {
...(existingRow.data as RowData),
...(validated.data as RowData),
}
const validation = await validateRowData({
rowData: mergedData,
schema: table.schema as TableSchema,
tableId,
excludeRowId: rowId,
})
if (!validation.valid) return validation.response
const now = new Date()
const [updatedRow] = await db
.update(userTableRows)
.set({
const updatedRow = await updateRow(
{
tableId,
rowId,
data: mergedData,
updatedAt: now,
})
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()
if (!updatedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Updated row ${rowId} in table ${tableId}`)
workspaceId: validated.workspaceId,
},
table,
requestId
)
return NextResponse.json({
success: true,
@@ -192,8 +170,14 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
row: {
id: updatedRow.id,
data: updatedRow.data,
createdAt: updatedRow.createdAt.toISOString(),
updatedAt: updatedRow.updatedAt.toISOString(),
createdAt:
updatedRow.createdAt instanceof Date
? updatedRow.createdAt.toISOString()
: updatedRow.createdAt,
updatedAt:
updatedRow.updatedAt instanceof Date
? updatedRow.updatedAt.toISOString()
: updatedRow.updatedAt,
},
message: 'Row updated successfully',
},
@@ -206,6 +190,22 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage === 'Row not found') {
return NextResponse.json({ error: errorMessage }, { status: 404 })
}
if (
errorMessage.includes('Row size exceeds') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('Cannot set unique column')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error updating row:`, error)
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
}
@@ -222,7 +222,13 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = DeleteRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
@@ -230,11 +236,7 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

View File

@@ -8,17 +8,19 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
import {
checkUniqueConstraintsDb,
getUniqueColumns,
batchInsertRows,
deleteRowsByFilter,
deleteRowsByIds,
insertRow,
TABLE_LIMITS,
USER_TABLE_ROWS_SQL_NAME,
updateRowsByFilter,
validateBatchRows,
validateRowAgainstSchema,
validateRowData,
validateRowSize,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
import { accessError, checkAccess } from '../../utils'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableRowsAPI')
@@ -54,27 +56,32 @@ const QueryRowsSchema = z.object({
.default(0),
})
const nonEmptyFilter = z
.record(z.unknown(), { required_error: 'Filter criteria is required' })
.refine((f) => Object.keys(f).length > 0, { message: 'Filter must not be empty' })
const optionalPositiveLimit = (max: number, label: string) =>
z.preprocess(
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
z
.number()
.int(`${label} must be an integer`)
.min(1, `${label} must be at least 1`)
.max(max, `Cannot ${label.toLowerCase()} more than ${max} rows per operation`)
.optional()
)
const UpdateRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
filter: nonEmptyFilter,
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(1000, 'Cannot update more than 1000 rows per operation')
.optional(),
limit: optionalPositiveLimit(1000, 'Limit'),
})
const DeleteRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(1000, 'Cannot delete more than 1000 rows per operation')
.optional(),
filter: nonEmptyFilter,
limit: optionalPositiveLimit(1000, 'Limit'),
})
const DeleteRowsByIdsSchema = z.object({
@@ -111,18 +118,8 @@ async function handleBatchInsert(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const workspaceId = validated.workspaceId
const remainingCapacity = table.maxRows - table.rowCount
if (remainingCapacity < validated.rows.length) {
return NextResponse.json(
{
error: `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${table.rowCount}/${table.maxRows} rows)`,
},
{ status: 400 }
)
}
// Validate rows before calling service (service also validates, but route-level
// validation returns structured HTTP responses)
const validation = await validateBatchRows({
rows: validated.rows as RowData[],
schema: table.schema as TableSchema,
@@ -130,34 +127,48 @@ async function handleBatchInsert(
})
if (!validation.valid) return validation.response
const now = new Date()
const rowsToInsert = validated.rows.map((data) => ({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId,
workspaceId,
data,
createdAt: now,
updatedAt: now,
createdBy: userId,
}))
try {
const insertedRows = await batchInsertRows(
{
tableId,
rows: validated.rows as RowData[],
workspaceId: validated.workspaceId,
userId,
},
table,
requestId
)
const insertedRows = await db.insert(userTableRows).values(rowsToInsert).returning()
return NextResponse.json({
success: true,
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
})),
insertedCount: insertedRows.length,
message: `Successfully inserted ${insertedRows.length} rows`,
},
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.info(`[${requestId}] Batch inserted ${insertedRows.length} rows into table ${tableId}`)
if (
errorMessage.includes('row limit') ||
errorMessage.includes('Insufficient capacity') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Row size exceeds') ||
errorMessage.match(/^Row \d+:/)
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
return NextResponse.json({
success: true,
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
insertedCount: insertedRows.length,
message: `Successfully inserted ${insertedRows.length} rows`,
},
})
logger.error(`[${requestId}] Error batch inserting rows:`, error)
return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 })
}
}
/** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */
@@ -171,7 +182,12 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
if (
typeof body === 'object' &&
@@ -201,9 +217,9 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const workspaceId = validated.workspaceId
const rowData = validated.data as RowData
// Validate at route level for structured HTTP error responses
const validation = await validateRowData({
rowData,
schema: table.schema as TableSchema,
@@ -211,30 +227,17 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
})
if (!validation.valid) return validation.response
if (table.rowCount >= table.maxRows) {
return NextResponse.json(
{ error: `Table row limit reached (${table.maxRows} rows max)` },
{ status: 400 }
)
}
const rowId = `row_${crypto.randomUUID().replace(/-/g, '')}`
const now = new Date()
const [row] = await db
.insert(userTableRows)
.values({
id: rowId,
// Service handles atomic capacity check + insert in a transaction
const row = await insertRow(
{
tableId,
workspaceId,
data: validated.data,
createdAt: now,
updatedAt: now,
createdBy: authResult.userId,
})
.returning()
logger.info(`[${requestId}] Inserted row ${rowId} into table ${tableId}`)
data: rowData,
workspaceId: validated.workspaceId,
userId: authResult.userId,
},
table,
requestId
)
return NextResponse.json({
success: true,
@@ -242,8 +245,8 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
row: {
id: row.id,
data: row.data,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
},
message: 'Row inserted successfully',
},
@@ -256,6 +259,18 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (
errorMessage.includes('row limit') ||
errorMessage.includes('Insufficient capacity') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Row size exceeds')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error inserting row:`, error)
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
}
@@ -364,8 +379,8 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
rows: rows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
rowCount: rows.length,
totalCount: Number(totalCount),
@@ -397,7 +412,13 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = UpdateRowsByFilterSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
@@ -412,9 +433,7 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updateData = validated.data as RowData
const sizeValidation = validateRowSize(updateData)
const sizeValidation = validateRowSize(validated.data as RowData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
@@ -422,31 +441,19 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
)
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
const result = await updateRowsByFilter(
{
tableId,
filter: validated.filter as Filter,
data: validated.data as RowData,
limit: validated.limit,
workspaceId: validated.workspaceId,
},
table,
requestId
)
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
let matchingRowsQuery = db
.select({
id: userTableRows.id,
data: userTableRows.data,
})
.from(userTableRows)
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery
if (matchingRows.length === 0) {
if (result.affectedCount === 0) {
return NextResponse.json(
{
success: true,
@@ -459,103 +466,12 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
)
}
if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
logger.warn(`[${requestId}] Updating ${matchingRows.length} rows. This may take some time.`)
}
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema)
if (!rowValidation.valid) {
return NextResponse.json(
{
error: 'Updated data does not match schema',
details: rowValidation.errors,
affectedRowId: row.id,
},
{ status: 400 }
)
}
}
const uniqueColumns = getUniqueColumns(table.schema as TableSchema)
if (uniqueColumns.length > 0) {
// If updating multiple rows, check that updateData doesn't set any unique column
// (would cause all rows to have the same value, violating uniqueness)
if (matchingRows.length > 1) {
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in updateData)
if (uniqueColumnsInUpdate.length > 0) {
return NextResponse.json(
{
error: 'Cannot set unique column values when updating multiple rows',
details: [
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`,
],
},
{ status: 400 }
)
}
}
// Check unique constraints against database for each row
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const uniqueValidation = await checkUniqueConstraintsDb(
tableId,
mergedData,
table.schema as TableSchema,
row.id
)
if (!uniqueValidation.valid) {
return NextResponse.json(
{
error: 'Unique constraint violation',
details: uniqueValidation.errors,
affectedRowId: row.id,
},
{ status: 400 }
)
}
}
}
const now = new Date()
await db.transaction(async (trx) => {
let totalUpdated = 0
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const updatePromises = batch.map((row) => {
const existingData = row.data as RowData
return trx
.update(userTableRows)
.set({
data: { ...existingData, ...updateData },
updatedAt: now,
})
.where(eq(userTableRows.id, row.id))
})
await Promise.all(updatePromises)
totalUpdated += batch.length
logger.info(
`[${requestId}] Updated batch ${Math.floor(i / TABLE_LIMITS.UPDATE_BATCH_SIZE) + 1} (${totalUpdated}/${matchingRows.length} rows)`
)
}
})
logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${tableId}`)
return NextResponse.json({
success: true,
data: {
message: 'Rows updated successfully',
updatedCount: matchingRows.length,
updatedRowIds: matchingRows.map((r) => r.id),
updatedCount: result.affectedCount,
updatedRowIds: result.affectedRowIds,
},
})
} catch (error) {
@@ -566,16 +482,25 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
)
}
logger.error(`[${requestId}] Error updating rows by filter:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to update rows: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
if (
errorMessage.includes('Row size exceeds') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('Cannot set unique column') ||
errorMessage.includes('Filter is required')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error updating rows by filter:`, error)
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
}
}
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria. */
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria or by IDs. */
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
@@ -586,7 +511,13 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = DeleteRowsRequestSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
@@ -601,110 +532,46 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
let rowIds: string[] = []
let missingRowIds: string[] | undefined
let requestedCount: number | undefined
if ('rowIds' in validated) {
const uniqueRequestedRowIds = Array.from(new Set(validated.rowIds))
requestedCount = uniqueRequestedRowIds.length
const matchingRows = await db
.select({ id: userTableRows.id })
.from(userTableRows)
.where(
and(
...baseConditions,
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
uniqueRequestedRowIds.map((id) => sql`${id}`),
sql`, `
)}])`
)
)
const matchedRowIds = matchingRows.map((r) => r.id)
const matchedIdSet = new Set(matchedRowIds)
missingRowIds = uniqueRequestedRowIds.filter((id) => !matchedIdSet.has(id))
rowIds = matchedRowIds
} else {
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
let matchingRowsQuery = db
.select({ id: userTableRows.id })
.from(userTableRows)
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery
rowIds = matchingRows.map((r) => r.id)
}
if (rowIds.length === 0) {
return NextResponse.json(
{
success: true,
data: {
message:
'rowIds' in validated
? 'No matching rows found for the provided IDs'
: 'No rows matched the filter criteria',
deletedCount: 0,
deletedRowIds: [],
...(requestedCount !== undefined ? { requestedCount } : {}),
...(missingRowIds ? { missingRowIds } : {}),
},
},
{ status: 200 }
const result = await deleteRowsByIds(
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
requestId
)
return NextResponse.json({
success: true,
data: {
message:
result.deletedCount === 0
? 'No matching rows found for the provided IDs'
: 'Rows deleted successfully',
deletedCount: result.deletedCount,
deletedRowIds: result.deletedRowIds,
requestedCount: result.requestedCount,
...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}),
},
})
}
if (rowIds.length > TABLE_LIMITS.DELETE_BATCH_SIZE) {
logger.warn(`[${requestId}] Deleting ${rowIds.length} rows. This may take some time.`)
}
await db.transaction(async (trx) => {
let totalDeleted = 0
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
await trx.delete(userTableRows).where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
batch.map((id) => sql`${id}`),
sql`, `
)}])`
)
)
totalDeleted += batch.length
logger.info(
`[${requestId}] Deleted batch ${Math.floor(i / TABLE_LIMITS.DELETE_BATCH_SIZE) + 1} (${totalDeleted}/${rowIds.length} rows)`
)
}
})
logger.info(`[${requestId}] Deleted ${rowIds.length} rows from table ${tableId}`)
const result = await deleteRowsByFilter(
{
tableId,
filter: validated.filter as Filter,
limit: validated.limit,
workspaceId: validated.workspaceId,
},
requestId
)
return NextResponse.json({
success: true,
data: {
message: 'Rows deleted successfully',
deletedCount: rowIds.length,
deletedRowIds: rowIds,
...(requestedCount !== undefined ? { requestedCount } : {}),
...(missingRowIds ? { missingRowIds } : {}),
message:
result.affectedCount === 0
? 'No rows matched the filter criteria'
: 'Rows deleted successfully',
deletedCount: result.affectedCount,
deletedRowIds: result.affectedRowIds,
},
})
} catch (error) {
@@ -715,11 +582,13 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
)
}
logger.error(`[${requestId}] Error deleting rows by filter:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to delete rows: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
if (errorMessage.includes('Filter is required')) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error deleting rows:`, error)
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
}
}

View File

@@ -1,20 +1,18 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or, sql } 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 type { RowData, TableSchema } from '@/lib/table'
import { getUniqueColumns, validateRowData } from '@/lib/table'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
import type { RowData } from '@/lib/table'
import { upsertRow } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableUpsertAPI')
const UpsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
conflictTarget: z.string().optional(),
})
interface UpsertRouteParams {
@@ -32,7 +30,13 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = UpsertRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
@@ -40,115 +44,20 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const schema = table.schema as TableSchema
const rowData = validated.data as RowData
const validation = await validateRowData({
rowData,
schema,
tableId,
checkUnique: false,
})
if (!validation.valid) return validation.response
const uniqueColumns = getUniqueColumns(schema)
if (uniqueColumns.length === 0) {
return NextResponse.json(
{
error:
'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.',
},
{ status: 400 }
)
}
const uniqueFilters = uniqueColumns.map((col) => {
const value = rowData[col.name]
if (value === undefined || value === null) {
return null
}
return sql`${userTableRows.data}->>${col.name} = ${String(value)}`
})
const validUniqueFilters = uniqueFilters.filter((f): f is Exclude<typeof f, null> => f !== null)
if (validUniqueFilters.length === 0) {
return NextResponse.json(
{
error: `Upsert requires values for at least one unique field: ${uniqueColumns.map((c) => c.name).join(', ')}`,
},
{ status: 400 }
)
}
const [existingRow] = await db
.select()
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
or(...validUniqueFilters)
)
)
.limit(1)
const now = new Date()
if (!existingRow && table.rowCount >= table.maxRows) {
return NextResponse.json(
{ error: `Table row limit reached (${table.maxRows} rows max)` },
{ status: 400 }
)
}
const upsertResult = await db.transaction(async (trx) => {
if (existingRow) {
const [updatedRow] = await trx
.update(userTableRows)
.set({
data: validated.data,
updatedAt: now,
})
.where(eq(userTableRows.id, existingRow.id))
.returning()
return {
row: updatedRow,
operation: 'update' as const,
}
}
const [insertedRow] = await trx
.insert(userTableRows)
.values({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId,
workspaceId: validated.workspaceId,
data: validated.data,
createdAt: now,
updatedAt: now,
createdBy: authResult.userId,
})
.returning()
return {
row: insertedRow,
operation: 'insert' as const,
}
})
logger.info(
`[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}`
const upsertResult = await upsertRow(
{
tableId,
workspaceId: validated.workspaceId,
data: validated.data as RowData,
userId: authResult.userId,
conflictTarget: validated.conflictTarget,
},
table,
requestId
)
return NextResponse.json({
@@ -157,8 +66,14 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
row: {
id: upsertResult.row.id,
data: upsertResult.row.data,
createdAt: upsertResult.row.createdAt.toISOString(),
updatedAt: upsertResult.row.updatedAt.toISOString(),
createdAt:
upsertResult.row.createdAt instanceof Date
? upsertResult.row.createdAt.toISOString()
: upsertResult.row.createdAt,
updatedAt:
upsertResult.row.updatedAt instanceof Date
? upsertResult.row.updatedAt.toISOString()
: upsertResult.row.updatedAt,
},
operation: upsertResult.operation,
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
@@ -172,11 +87,22 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
)
}
logger.error(`[${requestId}] Error upserting row:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to upsert row: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
// Service layer throws descriptive errors for validation/capacity issues
if (
errorMessage.includes('unique column') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('conflictTarget') ||
errorMessage.includes('row limit') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('Upsert requires') ||
errorMessage.includes('Row size exceeds')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error upserting row:`, error)
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
}
}

View File

@@ -4,7 +4,6 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
canCreateTable,
createTable,
getWorkspaceTableLimits,
listTables,
@@ -12,7 +11,7 @@ import {
type TableSchema,
} from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { normalizeColumn } from './utils'
import { normalizeColumn } from '@/app/api/table/utils'
const logger = createLogger('TableAPI')
@@ -101,7 +100,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const params = CreateTableSchema.parse(body)
const { hasAccess, canWrite } = await checkWorkspaceAccess(
@@ -113,22 +118,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check billing plan limits
const existingTables = await listTables(params.workspaceId)
const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length)
if (!canCreate) {
return NextResponse.json(
{
error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`,
},
{ status: 403 }
)
}
// Get plan-based row limits
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
const maxRowsPerTable = planLimits.maxRowsPerTable
const normalizedSchema: TableSchema = {
columns: params.schema.columns.map(normalizeColumn),
@@ -141,7 +131,8 @@ export async function POST(request: NextRequest) {
schema: normalizedSchema,
workspaceId: params.workspaceId,
userId: authResult.userId,
maxRows: maxRowsPerTable,
maxRows: planLimits.maxRowsPerTable,
maxTables: planLimits.maxTables,
},
requestId
)
@@ -153,7 +144,9 @@ export async function POST(request: NextRequest) {
id: table.id,
name: table.name,
description: table.description,
schema: table.schema,
schema: {
columns: (table.schema as TableSchema).columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
@@ -177,11 +170,13 @@ export async function POST(request: NextRequest) {
}
if (error instanceof Error) {
if (error.message.includes('maximum table limit')) {
return NextResponse.json({ error: error.message }, { status: 403 })
}
if (
error.message.includes('Invalid table name') ||
error.message.includes('Invalid schema') ||
error.message.includes('already exists') ||
error.message.includes('maximum table limit')
error.message.includes('already exists')
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
@@ -231,10 +226,14 @@ export async function GET(request: NextRequest) {
tables: tables.map((t) => {
const schemaData = t.schema as TableSchema
return {
...t,
id: t.id,
name: t.name,
description: t.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: t.rowCount,
maxRows: t.maxRows,
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:

View File

@@ -31,7 +31,7 @@ const SettingsSchema = z.object({
})
const defaultSettings = {
theme: 'dark',
theme: 'system',
autoConnect: true,
telemetryEnabled: true,
emailPreferences: {},

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
@@ -75,8 +74,6 @@ export async function POST(req: NextRequest) {
model: selectedModel,
mode: transportMode,
messageId: crypto.randomUUID(),
version: SIM_AGENT_VERSION,
headless: true,
chatId,
}
@@ -84,6 +81,7 @@ export async function POST(req: NextRequest) {
userId: auth.userId,
workflowId: resolved.workflowId,
chatId,
goRoute: '/api/mcp',
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,
interactive: false,
@@ -93,8 +91,7 @@ export async function POST(req: NextRequest) {
success: result.success,
content: result.content,
toolCalls: result.toolCalls,
chatId: result.chatId || chatId, // Return the chatId for conversation continuity
conversationId: result.conversationId,
chatId: result.chatId || chatId,
error: result.error,
})
} catch (error) {

View File

@@ -0,0 +1,158 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import {
deleteWorkspaceFile,
downloadWorkspaceFile,
getWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1FileDetailAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const WorkspaceIdSchema = z.object({
workspaceId: z.string().min(1, 'workspaceId query parameter is required'),
})
interface FileRouteParams {
params: Promise<{ fileId: string }>
}
/** GET /api/v1/files/[fileId] — Download file content. */
export async function GET(request: NextRequest, { params }: FileRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'file-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { fileId } = await params
const { searchParams } = new URL(request.url)
const validation = WorkspaceIdSchema.safeParse({
workspaceId: searchParams.get('workspaceId'),
})
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation error', details: validation.error.errors },
{ status: 400 }
)
}
const { workspaceId } = validation.data
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission === null) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const buffer = await downloadWorkspaceFile(fileRecord)
return new Response(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': fileRecord.type || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
'Content-Length': String(buffer.length),
'X-File-Id': fileRecord.id,
'X-File-Name': encodeURIComponent(fileRecord.name),
'X-Uploaded-At':
fileRecord.uploadedAt instanceof Date
? fileRecord.uploadedAt.toISOString()
: String(fileRecord.uploadedAt),
},
})
} catch (error) {
logger.error(`[${requestId}] Error downloading file:`, error)
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
}
}
/** DELETE /api/v1/files/[fileId] — Delete a file. */
export async function DELETE(request: NextRequest, { params }: FileRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'file-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { fileId } = await params
const { searchParams } = new URL(request.url)
const validation = WorkspaceIdSchema.safeParse({
workspaceId: searchParams.get('workspaceId'),
})
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation error', details: validation.error.errors },
{ status: 400 }
)
}
const { workspaceId } = validation.data
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission === null || permission === 'read') {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
await deleteWorkspaceFile(workspaceId, fileId)
logger.info(
`[${requestId}] Deleted file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`
)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FILE_DELETED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: fileRecord.name,
description: `Deleted file "${fileRecord.name}" via API`,
request,
})
return NextResponse.json({
success: true,
data: {
message: 'File deleted successfully',
},
})
} catch (error) {
logger.error(`[${requestId}] Error deleting file:`, error)
return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 })
}
}

View File

@@ -0,0 +1,194 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import {
getWorkspaceFile,
listWorkspaceFiles,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1FilesAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
const ListFilesSchema = z.object({
workspaceId: z.string().min(1, 'workspaceId query parameter is required'),
})
/** GET /api/v1/files — List all files in a workspace. */
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'files')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { searchParams } = new URL(request.url)
const validation = ListFilesSchema.safeParse({
workspaceId: searchParams.get('workspaceId'),
})
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation error', details: validation.error.errors },
{ status: 400 }
)
}
const { workspaceId } = validation.data
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission === null) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const files = await listWorkspaceFiles(workspaceId)
return NextResponse.json({
success: true,
data: {
files: files.map((f) => ({
id: f.id,
name: f.name,
size: f.size,
type: f.type,
key: f.key,
uploadedBy: f.uploadedBy,
uploadedAt:
f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt),
})),
totalCount: files.length,
},
})
} catch (error) {
logger.error(`[${requestId}] Error listing files:`, error)
return NextResponse.json({ error: 'Failed to list files' }, { status: 500 })
}
}
/** POST /api/v1/files — Upload a file to a workspace. */
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'files')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json(
{ error: 'Request body must be valid multipart form data' },
{ status: 400 }
)
}
const rawFile = formData.get('file')
const file = rawFile instanceof File ? rawFile : null
const rawWorkspaceId = formData.get('workspaceId')
const workspaceId = typeof rawWorkspaceId === 'string' ? rawWorkspaceId : null
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId form field is required' }, { status: 400 })
}
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
if (!file) {
return NextResponse.json({ error: 'file form field is required' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{
error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`,
},
{ status: 400 }
)
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission === null || permission === 'read') {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const userFile = await uploadWorkspaceFile(
workspaceId,
userId,
buffer,
file.name,
file.type || 'application/octet-stream'
)
logger.info(`[${requestId}] Uploaded file: ${file.name} to workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: file.name,
description: `Uploaded file "${file.name}" via API`,
request,
})
const fileRecord = await getWorkspaceFile(workspaceId, userFile.id)
const uploadedAt =
fileRecord?.uploadedAt instanceof Date
? fileRecord.uploadedAt.toISOString()
: fileRecord?.uploadedAt
? String(fileRecord.uploadedAt)
: new Date().toISOString()
return NextResponse.json({
success: true,
data: {
file: {
id: userFile.id,
name: userFile.name,
size: userFile.size,
type: userFile.type,
key: userFile.key,
uploadedBy: userId,
uploadedAt,
},
message: 'File uploaded successfully',
},
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to upload file'
const isDuplicate = errorMessage.includes('already exists')
if (isDuplicate) {
return NextResponse.json({ error: errorMessage }, { status: 409 })
}
logger.error(`[${requestId}] Error uploading file:`, error)
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
}

View File

@@ -1,7 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter'
import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter'
import { authenticateV1Request } from '@/app/api/v1/auth'
const logger = createLogger('V1Middleware')
@@ -14,12 +15,25 @@ export interface RateLimitResult {
limit: number
retryAfterMs?: number
userId?: string
workspaceId?: string
keyType?: 'personal' | 'workspace'
error?: string
}
export async function checkRateLimit(
request: NextRequest,
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' | 'audit-logs' = 'logs'
endpoint:
| 'logs'
| 'logs-detail'
| 'workflows'
| 'workflow-detail'
| 'audit-logs'
| 'tables'
| 'table-detail'
| 'table-rows'
| 'table-row-detail'
| 'files'
| 'file-detail' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)
@@ -51,20 +65,18 @@ export async function checkRateLimit(
})
}
const rateLimitStatus = await rateLimiter.getRateLimitStatusWithSubscription(
userId,
subscription,
'api-endpoint',
false
)
const plan = (subscription?.plan || 'free') as SubscriptionPlan
const config = getRateLimit(plan, 'api-endpoint')
return {
allowed: result.allowed,
remaining: result.remaining,
resetAt: result.resetAt,
limit: rateLimitStatus.requestsPerMinute,
limit: config.refillRate,
retryAfterMs: result.retryAfterMs,
userId,
workspaceId: auth.workspaceId,
keyType: auth.keyType,
}
} catch (error) {
logger.error('Rate limit check error', { error })
@@ -89,26 +101,40 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse {
return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401, headers })
}
if (!result.allowed) {
const retryAfterSeconds = result.retryAfterMs
? Math.ceil(result.retryAfterMs / 1000)
: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)
const retryAfterSeconds = result.retryAfterMs
? Math.ceil(result.retryAfterMs / 1000)
: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)
return NextResponse.json(
{
error: 'Rate limit exceeded',
message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`,
retryAfter: result.resetAt.getTime(),
return NextResponse.json(
{
error: 'Rate limit exceeded',
message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`,
retryAfter: result.resetAt.getTime(),
},
{
status: 429,
headers: {
...headers,
'Retry-After': retryAfterSeconds.toString(),
},
{
status: 429,
headers: {
...headers,
'Retry-After': retryAfterSeconds.toString(),
},
}
}
)
}
/** Verify that a workspace-scoped API key is only used for its own workspace. */
export function checkWorkspaceScope(
rateLimit: RateLimitResult,
requestedWorkspaceId: string
): NextResponse | null {
if (
rateLimit.keyType === 'workspace' &&
rateLimit.workspaceId &&
rateLimit.workspaceId !== requestedWorkspaceId
) {
return NextResponse.json(
{ error: 'API key is not authorized for this workspace' },
{ status: 403 }
)
}
return NextResponse.json({ error: 'Bad request' }, { status: 400, headers })
return null
}

View File

@@ -0,0 +1,142 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteTable, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableDetailAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface TableRouteParams {
params: Promise<{ tableId: string }>
}
/** GET /api/v1/tables/[tableId] — Get table details. */
export async function GET(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId } = await params
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json(
{ error: 'workspaceId query parameter is required' },
{ status: 400 }
)
}
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const schemaData = table.schema as TableSchema
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
},
})
} catch (error) {
logger.error(`[${requestId}] Error getting table:`, error)
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
}
}
/** DELETE /api/v1/tables/[tableId] — Delete a table. */
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId } = await params
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json(
{ error: 'workspaceId query parameter is required' },
{ status: 400 }
)
}
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
await deleteTable(tableId, requestId)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.TABLE_DELETED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: result.table.name,
description: `Deleted table "${result.table.name}"`,
request,
})
return NextResponse.json({
success: true,
data: {
message: 'Table deleted successfully',
},
})
} catch (error) {
logger.error(`[${requestId}] Error deleting table:`, error)
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
}
}

View File

@@ -0,0 +1,275 @@
import { db } from '@sim/db'
import { userTableRows } 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 { generateRequestId } from '@/lib/core/utils/request'
import type { RowData } from '@/lib/table'
import { updateRow } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableRowAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const UpdateRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
interface RowRouteParams {
params: Promise<{ tableId: string; rowId: string }>
}
/** GET /api/v1/tables/[tableId]/rows/[rowId] — Get a single row. */
export async function GET(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-row-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId, rowId } = await params
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json(
{ error: 'workspaceId query parameter is required' },
{ status: 400 }
)
}
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [row] = await db
.select({
id: userTableRows.id,
data: userTableRows.data,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
updatedAt:
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
},
},
})
} catch (error) {
logger.error(`[${requestId}] Error getting row:`, error)
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
}
}
/** PATCH /api/v1/tables/[tableId]/rows/[rowId] — Partial update a single row. */
export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-row-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId, rowId } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = UpdateRowSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// Fetch existing row to merge partial update
const [existingRow] = await db
.select({ data: userTableRows.data })
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.limit(1)
if (!existingRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
const mergedData = {
...(existingRow.data as RowData),
...(validated.data as RowData),
}
const updatedRow = await updateRow(
{
tableId,
rowId,
data: mergedData,
workspaceId: validated.workspaceId,
},
table,
requestId
)
return NextResponse.json({
success: true,
data: {
row: {
id: updatedRow.id,
data: updatedRow.data,
createdAt:
updatedRow.createdAt instanceof Date
? updatedRow.createdAt.toISOString()
: updatedRow.createdAt,
updatedAt:
updatedRow.updatedAt instanceof Date
? updatedRow.updatedAt.toISOString()
: updatedRow.updatedAt,
},
message: 'Row updated successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage === 'Row not found') {
return NextResponse.json({ error: errorMessage }, { status: 404 })
}
if (
errorMessage.includes('Row size exceeds') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('Cannot set unique column')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error updating row:`, error)
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
}
}
/** DELETE /api/v1/tables/[tableId]/rows/[rowId] — Delete a single row. */
export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-row-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId, rowId } = await params
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json(
{ error: 'workspaceId query parameter is required' },
{ status: 400 }
)
}
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [deletedRow] = await db
.delete(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
.returning()
if (!deletedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
return NextResponse.json({
success: true,
data: {
message: 'Row deleted successfully',
deletedCount: 1,
},
})
} catch (error) {
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}
}

View File

@@ -0,0 +1,597 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { generateRequestId } from '@/lib/core/utils/request'
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
import {
batchInsertRows,
deleteRowsByFilter,
deleteRowsByIds,
insertRow,
TABLE_LIMITS,
USER_TABLE_ROWS_SQL_NAME,
updateRowsByFilter,
validateBatchRows,
validateRowData,
validateRowSize,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableRowsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const InsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
const BatchInsertRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
rows: z
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
.min(1, 'At least one row is required')
.max(1000, 'Cannot insert more than 1000 rows per batch'),
})
const QueryRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown()).optional(),
sort: z.record(z.enum(['asc', 'desc'])).optional(),
limit: z
.preprocess(
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
z
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`)
.optional()
)
.default(100),
offset: z
.preprocess(
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
z
.number({ required_error: 'Offset must be a number' })
.int('Offset must be an integer')
.min(0, 'Offset must be 0 or greater')
.optional()
)
.default(0),
})
const nonEmptyFilter = z
.record(z.unknown(), { required_error: 'Filter criteria is required' })
.refine((f) => Object.keys(f).length > 0, { message: 'Filter must not be empty' })
const optionalPositiveLimit = (max: number, label: string) =>
z.preprocess(
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
z
.number()
.int(`${label} must be an integer`)
.min(1, `${label} must be at least 1`)
.max(max, `Cannot ${label.toLowerCase()} more than ${max} rows per operation`)
.optional()
)
const UpdateRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: nonEmptyFilter,
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
limit: optionalPositiveLimit(1000, 'Limit'),
})
const DeleteRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: nonEmptyFilter,
limit: optionalPositiveLimit(1000, 'Limit'),
})
const DeleteRowsByIdsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
rowIds: z
.array(z.string().min(1), { required_error: 'Row IDs are required' })
.min(1, 'At least one row ID is required')
.max(1000, 'Cannot delete more than 1000 rows per operation'),
})
const DeleteRowsRequestSchema = z.union([DeleteRowsByFilterSchema, DeleteRowsByIdsSchema])
interface TableRowsRouteParams {
params: Promise<{ tableId: string }>
}
async function handleBatchInsert(
requestId: string,
tableId: string,
validated: z.infer<typeof BatchInsertRowsSchema>,
userId: string
): Promise<NextResponse> {
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const validation = await validateBatchRows({
rows: validated.rows as RowData[],
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
try {
const insertedRows = await batchInsertRows(
{
tableId,
rows: validated.rows as RowData[],
workspaceId: validated.workspaceId,
userId,
},
table,
requestId
)
return NextResponse.json({
success: true,
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
})),
insertedCount: insertedRows.length,
message: `Successfully inserted ${insertedRows.length} rows`,
},
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (
errorMessage.includes('row limit') ||
errorMessage.includes('Insufficient capacity') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Row size exceeds') ||
errorMessage.match(/^Row \d+:/)
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error batch inserting rows:`, error)
return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 })
}
}
/** GET /api/v1/tables/[tableId]/rows — Query rows with filtering, sorting, pagination. */
export async function GET(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId } = await params
const { searchParams } = new URL(request.url)
let filter: Record<string, unknown> | undefined
let sort: Sort | undefined
try {
const filterParam = searchParams.get('filter')
const sortParam = searchParams.get('sort')
if (filterParam) {
filter = JSON.parse(filterParam) as Record<string, unknown>
}
if (sortParam) {
sort = JSON.parse(sortParam) as Sort
}
} catch {
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
}
const validated = QueryRowsSchema.parse({
workspaceId: searchParams.get('workspaceId'),
filter,
sort,
limit: searchParams.get('limit'),
offset: searchParams.get('offset'),
})
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'read')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
if (validated.filter) {
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
}
let query = db
.select({
id: userTableRows.id,
data: userTableRows.data,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
.from(userTableRows)
.where(and(...baseConditions))
if (validated.sort) {
const schema = table.schema as TableSchema
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
if (sortClause) {
query = query.orderBy(sortClause) as typeof query
}
} else {
query = query.orderBy(userTableRows.createdAt) as typeof query
}
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(userTableRows)
.where(and(...baseConditions))
const [countResult, rows] = await Promise.all([
countQuery,
query.limit(validated.limit).offset(validated.offset),
])
const totalCount = countResult[0].count
return NextResponse.json({
success: true,
data: {
rows: rows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
rowCount: rows.length,
totalCount: Number(totalCount),
limit: validated.limit,
offset: validated.offset,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error querying rows:`, error)
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
}
}
/** POST /api/v1/tables/[tableId]/rows — Insert row(s). Supports single or batch. */
export async function POST(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
if (
typeof body === 'object' &&
body !== null &&
'rows' in body &&
Array.isArray((body as Record<string, unknown>).rows)
) {
const batchValidated = BatchInsertRowsSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, batchValidated.workspaceId)
if (scopeError) return scopeError
return handleBatchInsert(requestId, tableId, batchValidated, userId)
}
const validated = InsertRowSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const rowData = validated.data as RowData
const validation = await validateRowData({
rowData,
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
const row = await insertRow(
{
tableId,
data: rowData,
workspaceId: validated.workspaceId,
userId,
},
table,
requestId
)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
},
message: 'Row inserted successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (
errorMessage.includes('row limit') ||
errorMessage.includes('Insufficient capacity') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Row size exceeds')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error inserting row:`, error)
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
}
}
/** PUT /api/v1/tables/[tableId]/rows — Bulk update rows by filter. */
export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = UpdateRowsByFilterSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const sizeValidation = validateRowSize(validated.data as RowData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Validation error', details: sizeValidation.errors },
{ status: 400 }
)
}
const result = await updateRowsByFilter(
{
tableId,
filter: validated.filter as Filter,
data: validated.data as RowData,
limit: validated.limit,
workspaceId: validated.workspaceId,
},
table,
requestId
)
if (result.affectedCount === 0) {
return NextResponse.json({
success: true,
data: {
message: 'No rows matched the filter criteria',
updatedCount: 0,
},
})
}
return NextResponse.json({
success: true,
data: {
message: 'Rows updated successfully',
updatedCount: result.affectedCount,
updatedRowIds: result.affectedRowIds,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (
errorMessage.includes('Row size exceeds') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('Cannot set unique column') ||
errorMessage.includes('Filter is required')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error updating rows by filter:`, error)
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
}
}
/** DELETE /api/v1/tables/[tableId]/rows — Delete rows by filter or IDs. */
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = DeleteRowsRequestSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
if ('rowIds' in validated) {
const result = await deleteRowsByIds(
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
requestId
)
return NextResponse.json({
success: true,
data: {
message:
result.deletedCount === 0
? 'No matching rows found for the provided IDs'
: 'Rows deleted successfully',
deletedCount: result.deletedCount,
deletedRowIds: result.deletedRowIds,
requestedCount: result.requestedCount,
...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}),
},
})
}
const result = await deleteRowsByFilter(
{
tableId,
filter: validated.filter as Filter,
limit: validated.limit,
workspaceId: validated.workspaceId,
},
requestId
)
return NextResponse.json({
success: true,
data: {
message:
result.affectedCount === 0
? 'No rows matched the filter criteria'
: 'Rows deleted successfully',
deletedCount: result.affectedCount,
deletedRowIds: result.affectedRowIds,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage.includes('Filter is required')) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error deleting rows:`, error)
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
}
}

View File

@@ -0,0 +1,119 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData } from '@/lib/table'
import { upsertRow } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableUpsertAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const UpsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
conflictTarget: z.string().optional(),
})
interface UpsertRouteParams {
params: Promise<{ tableId: string }>
}
/** POST /api/v1/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */
export async function POST(request: NextRequest, { params }: UpsertRouteParams) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { tableId } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = UpsertRowSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const upsertResult = await upsertRow(
{
tableId,
workspaceId: validated.workspaceId,
data: validated.data as RowData,
userId,
conflictTarget: validated.conflictTarget,
},
table,
requestId
)
return NextResponse.json({
success: true,
data: {
row: {
id: upsertResult.row.id,
data: upsertResult.row.data,
createdAt:
upsertResult.row.createdAt instanceof Date
? upsertResult.row.createdAt.toISOString()
: upsertResult.row.createdAt,
updatedAt:
upsertResult.row.updatedAt instanceof Date
? upsertResult.row.updatedAt.toISOString()
: upsertResult.row.updatedAt,
},
operation: upsertResult.operation,
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (
errorMessage.includes('unique column') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('conflictTarget') ||
errorMessage.includes('row limit') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('Upsert requires') ||
errorMessage.includes('Row size exceeds')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error upserting row:`, error)
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
}
}

View File

@@ -0,0 +1,260 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createTable,
getWorkspaceTableLimits,
listTables,
TABLE_LIMITS,
type TableSchema,
} from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { normalizeColumn } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TablesAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const ListTablesSchema = z.object({
workspaceId: z.string().min(1, 'workspaceId query parameter is required'),
})
const ColumnSchema = z.object({
name: z
.string()
.min(1, 'Column name is required')
.max(
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
)
.regex(
/^[a-z_][a-z0-9_]*$/i,
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
),
type: z.enum(['string', 'number', 'boolean', 'date', 'json'], {
errorMap: () => ({
message: 'Column type must be one of: string, number, boolean, date, json',
}),
}),
required: z.boolean().optional().default(false),
unique: z.boolean().optional().default(false),
})
const CreateTableSchema = z.object({
name: z
.string()
.min(1, 'Table name is required')
.max(
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
`Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less`
)
.regex(
/^[a-z_][a-z0-9_]*$/i,
'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores'
),
description: z
.string()
.max(
TABLE_LIMITS.MAX_DESCRIPTION_LENGTH,
`Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`
)
.optional(),
schema: z.object({
columns: z
.array(ColumnSchema)
.min(1, 'Table must have at least one column')
.max(
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
`Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
),
}),
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
/** GET /api/v1/tables — List all tables in a workspace. */
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'tables')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { searchParams } = new URL(request.url)
const validation = ListTablesSchema.safeParse({
workspaceId: searchParams.get('workspaceId'),
})
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation error', details: validation.error.errors },
{ status: 400 }
)
}
const { workspaceId } = validation.data
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission === null) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const tables = await listTables(workspaceId)
return NextResponse.json({
success: true,
data: {
tables: tables.map((t) => {
const schemaData = t.schema as TableSchema
return {
id: t.id,
name: t.name,
description: t.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: t.rowCount,
maxRows: t.maxRows,
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
}
}),
totalCount: tables.length,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error listing tables:`, error)
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
}
}
/** POST /api/v1/tables — Create a new table. */
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'tables')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const params = CreateTableSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, params.workspaceId)
if (scopeError) return scopeError
const permission = await getUserEntityPermissions(userId, 'workspace', params.workspaceId)
if (permission === null || permission === 'read') {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
const normalizedSchema: TableSchema = {
columns: params.schema.columns.map(normalizeColumn),
}
const table = await createTable(
{
name: params.name,
description: params.description,
schema: normalizedSchema,
workspaceId: params.workspaceId,
userId,
maxRows: planLimits.maxRowsPerTable,
maxTables: planLimits.maxTables,
},
requestId
)
recordAudit({
workspaceId: params.workspaceId,
actorId: userId,
action: AuditAction.TABLE_CREATED,
resourceType: AuditResourceType.TABLE,
resourceId: table.id,
resourceName: table.name,
description: `Created table "${table.name}" via API`,
request,
})
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: {
columns: (table.schema as TableSchema).columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
message: 'Table created successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
if (error.message.includes('maximum table limit')) {
return NextResponse.json({ error: error.message }, { status: 403 })
}
if (
error.message.includes('Invalid table name') ||
error.message.includes('Invalid schema') ||
error.message.includes('already exists')
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error creating table:`, error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
}
}

View File

@@ -8,7 +8,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -108,6 +111,49 @@ const WorkflowStateSchema = z.object({
variables: z.any().optional(), // Workflow variables
})
/**
* GET /api/workflows/[id]/state
* Fetch the current workflow state from normalized tables.
* Used by the client after server-side edits (edit_workflow) to stay in sync.
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id: workflowId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'read',
})
if (!authorization.allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) {
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
}
return NextResponse.json({
blocks: normalized.blocks,
edges: normalized.edges,
loops: normalized.loops || {},
parallels: normalized.parallels || {},
})
} catch (error) {
logger.error('Failed to fetch workflow state', {
workflowId,
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT /api/workflows/[id]/state
* Save complete workflow state to normalized database tables

View File

@@ -240,7 +240,9 @@ describe('Workspace Invitation [invitationId] API Route', () => {
const response = await GET(request, { params })
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/workspace/workspace-456/home'
)
})
it('should redirect to error page with token preserved when invitation expired', async () => {
@@ -495,7 +497,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
expect(response2.status).toBe(307)
expect(response2.headers.get('location')).toBe(
'https://test.sim.ai/workspace/workspace-456/w'
'https://test.sim.ai/workspace/workspace-456/home'
)
})
})

View File

@@ -141,7 +141,7 @@ export async function GET(
.where(eq(workspaceInvitation.id, invitation.id))
return NextResponse.redirect(
new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl())
new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl())
)
}
@@ -193,7 +193,9 @@ export async function GET(
request: req,
})
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
return NextResponse.redirect(
new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl())
)
}
return NextResponse.json({

View File

@@ -113,7 +113,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
var width = state && state.sidebarWidth;
var maxSidebarWidth = window.innerWidth * 0.3;
if (width >= 232 && width <= maxSidebarWidth) {
if (width >= 248 && width <= maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
} else if (width > maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');

View File

@@ -3,18 +3,18 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
export async function GET() {
const baseUrl = getBaseUrl()
const llmsFullContent = `# Sim - AI Agent Workflow Builder
const llmsFullContent = `# Sim — Build AI Agents & Run Your Agentic Workforce
> Sim is an open-source AI agent workflow builder used by 70,000+ developers at startups to Fortune 500 companies. Build and deploy agentic workflows with a visual drag-and-drop canvas. 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.
## Overview
Sim provides a visual interface for building AI agent workflows. Instead of writing code, users drag and drop blocks onto a canvas and connect them to create complex AI automations. Each block represents a step in the workflow - an LLM call, a tool invocation, an API request, or a code execution.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that execute real workflows across systems, and manage them with full observability. SOC2 and HIPAA compliant.
## Product Details
- **Product Name**: Sim
- **Category**: AI Development Tools / Workflow Automation
- **Category**: AI Agent Platform / Agentic Workflow Orchestration
- **Deployment**: Cloud (SaaS) and Self-hosted options
- **Pricing**: Free tier, Pro ($20/month), Team ($40/month), Enterprise (custom)
- **Compliance**: SOC2 Type II, HIPAA compliant
@@ -66,7 +66,7 @@ Sim supports all major LLM providers:
- Amazon Bedrock
### Integrations
100+ pre-built integrations including:
1,000+ pre-built integrations including:
- **Communication**: Slack, Discord, Email (Gmail, Outlook), SMS (Twilio)
- **Productivity**: Notion, Airtable, Google Sheets, Google Docs
- **Development**: GitHub, GitLab, Jira, Linear
@@ -81,6 +81,12 @@ Built-in support for:
- Semantic search and retrieval
- Chunking strategies (fixed size, semantic, recursive)
### Tables
Built-in table creation and management:
- Structured data storage
- Queryable tables for agent workflows
- Native integrations
### Code Execution
- Sandboxed JavaScript/TypeScript execution
- Access to npm packages

View File

@@ -5,16 +5,16 @@ export async function GET() {
const llmsContent = `# Sim
> Sim is an open-source AI agent workflow builder. 70,000+ developers at startups to Fortune 500 companies deploy agentic workflows on the Sim platform. 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.
Sim provides a visual drag-and-drop interface for building and deploying AI agent workflows. Connect to 100+ integrations and ship production-ready AI automations.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 and HIPAA compliant.
## Core Pages
- [Homepage](${baseUrl}): Main landing page with product overview and features
- [Homepage](${baseUrl}): Product overview, features, and pricing
- [Templates](${baseUrl}/templates): Pre-built workflow templates to get started quickly
- [Changelog](${baseUrl}/changelog): Product updates and release notes
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides for AI workflows
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides
## Documentation
@@ -29,28 +29,31 @@ Sim provides a visual drag-and-drop interface for building and deploying AI agen
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
- **Trigger**: Event or schedule that initiates workflow execution
- **Execution**: A single run of a workflow with logs and outputs
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
## Capabilities
- Visual workflow builder with drag-and-drop canvas
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI)
- Retrieval-augmented generation (RAG) with vector databases
- 100+ integrations (Slack, Gmail, Notion, Airtable, databases)
- AI agent creation and deployment
- Agentic workflow orchestration
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
- Knowledge base creation with retrieval-augmented generation (RAG)
- Table creation and management
- Document creation and processing
- Scheduled and webhook-triggered executions
- Real-time collaboration and version control
## Use Cases
- AI agent workflow automation
- RAG pipelines and document processing
- Chatbot and copilot workflows for SaaS
- Email and customer support automation
- AI agent deployment and orchestration
- Knowledge bases and RAG pipelines
- Document creation and processing
- Customer support automation
- Internal operations (sales, marketing, legal, finance)
## Links
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with users
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
## Optional

View File

@@ -5,10 +5,10 @@ export default function manifest(): MetadataRoute.Manifest {
const brand = getBrandConfig()
return {
name: brand.name === 'Sim' ? 'Sim - AI Agent Workflow Builder' : brand.name,
name: brand.name === 'Sim' ? 'Sim — Build AI Agents & Run Your Agentic Workforce' : brand.name,
short_name: brand.name,
description:
'Open-source AI agent workflow builder. 70,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. 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 orchestrate agentic workflows.',
start_url: '/',
scope: '/',
display: 'standalone',

View File

@@ -1,16 +1,18 @@
import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Landing from '@/app/(landing)/landing'
import Landing from '@/app/(home)/landing'
export const dynamic = 'force-dynamic'
const baseUrl = getBaseUrl()
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: 'Sim - AI Agent Workflow Builder | Open Source Platform',
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Open-source AI agent workflow builder used by 70,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
keywords:
'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI',
'AI agents, agentic workforce, open-source AI agent platform, agentic workflows, LLM orchestration, AI automation, knowledge base, workflow builder, AI integrations, SOC2 compliant, HIPAA compliant, enterprise AI',
authors: [{ name: 'Sim' }],
creator: 'Sim',
publisher: 'Sim',
@@ -20,9 +22,9 @@ export const metadata: Metadata = {
telephone: false,
},
openGraph: {
title: 'Sim - AI Agent Workflow Builder | Open Source',
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Open-source platform used by 70,000+ developers. Design, deploy, and monitor agentic workflows with a visual drag-and-drop interface, 100+ integrations, and enterprise-grade security.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Join over 100,000 builders.',
type: 'website',
url: baseUrl,
siteName: 'Sim',
@@ -32,7 +34,7 @@ export const metadata: Metadata = {
url: '/logo/426-240/primary/small.png',
width: 2130,
height: 1200,
alt: 'Sim - AI Agent Workflow Builder',
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
type: 'image/png',
},
],
@@ -41,12 +43,12 @@ export const metadata: Metadata = {
card: 'summary_large_image',
site: '@simdotai',
creator: '@simdotai',
title: 'Sim - AI Agent Workflow Builder | Open Source',
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Open-source platform for agentic workflows. 70,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
images: {
url: '/logo/426-240/primary/small.png',
alt: 'Sim - AI Agent Workflow Builder',
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
},
},
alternates: {
@@ -72,11 +74,12 @@ export const metadata: Metadata = {
classification: 'AI Development Tools',
referrer: 'origin-when-cross-origin',
other: {
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
'llm:content-type':
'AI agent platform, agentic workforce, agentic workflows, LLM orchestration',
'llm:use-cases':
'email automation, Slack bots, Discord moderation, data analysis, customer support, content generation, agentic automations',
'AI agents, agentic workforce, agentic workflows, knowledge bases, tables, document creation, email automation, Slack bots, data analysis, customer support, content generation',
'llm:integrations':
'OpenAI, Anthropic, Google AI, Slack, Gmail, Discord, Notion, Airtable, Supabase',
'OpenAI, Anthropic, Google AI, Mistral, xAI, Perplexity, Slack, Gmail, Discord, Notion, Airtable, Supabase',
'llm:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
'llm:region': 'global',
'llm:languages': 'en',

View File

@@ -1,5 +1,38 @@
'use client'
import { NextError } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn'
export default NextError
const logger = createLogger('WorkspaceError')
interface WorkspaceErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function WorkspaceError({ error, reset }: WorkspaceErrorProps) {
useEffect(() => {
logger.error('Workspace error:', { error: error.message, digest: error.digest })
}, [error])
return (
<div className='flex h-full flex-1 items-center justify-center bg-white dark:bg-[var(--bg)]'>
<div className='flex flex-col items-center gap-[16px] text-center'>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
Something went wrong
</h2>
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
An unexpected error occurred. Please try again or refresh the page.
</p>
</div>
<Button variant='default' size='sm' onClick={reset}>
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
Try again
</Button>
</div>
</div>
)
}

View File

@@ -74,7 +74,7 @@ const PLAN_NAMES = {
free: 'Free',
} as const
export function Files() {
export function FileList() {
const params = useParams()
const workspaceId = params?.workspaceId as string
@@ -227,19 +227,19 @@ export function Files() {
const displayPlanName = PLAN_NAMES[planName as keyof typeof PLAN_NAMES] || 'Free'
const renderTableSkeleton = () => (
<Table className='table-fixed text-[13px]'>
<Table className='table-fixed text-[14px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[56%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[56%] px-[12px] py-[8px] text-[13px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[40px]' />
</TableHead>
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[28px]' />
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[56px]' />
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[48px]' />
</TableHead>
</TableRow>
@@ -253,10 +253,10 @@ export function Files() {
<Skeleton className='h-[14px] w-[180px]' />
</div>
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px]'>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px]'>
<Skeleton className='h-[12px] w-[48px]' />
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px]'>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px]'>
<Skeleton className='h-[12px] w-[56px]' />
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
@@ -272,80 +272,80 @@ export function Files() {
)
return (
<div className='flex h-full flex-col gap-[2px]'>
{/* Search Input and Upload Button */}
<div className='flex items-center gap-[8px]'>
<div className='flex h-full flex-col'>
{/* Search and Actions */}
<div className='mt-[14px] flex items-center justify-between'>
<div
className={cn(
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
'flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]',
permissionsLoading && 'opacity-50'
)}
>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
<Input
placeholder='Search files...'
placeholder='Search'
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={permissionsLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
/>
</div>
{(permissionsLoading || userPermissions.canEdit) && (
<>
<input
ref={fileInputRef}
type='file'
className='hidden'
onChange={handleFileChange}
disabled={uploading || permissionsLoading}
accept={ACCEPT_ATTR}
multiple
/>
<Button
onClick={handleUploadClick}
disabled={uploading || permissionsLoading}
variant='tertiary'
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
{uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload'}
</Button>
</>
)}
<div className='flex items-center gap-[8px]'>
{(permissionsLoading || userPermissions.canEdit) && (
<>
<input
ref={fileInputRef}
type='file'
className='hidden'
onChange={handleFileChange}
disabled={uploading || permissionsLoading}
accept={ACCEPT_ATTR}
multiple
/>
<Button
onClick={handleUploadClick}
disabled={uploading || permissionsLoading}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
<Plus className='mr-[6px] h-[14px] w-[14px]' />
{uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload'}
</Button>
</>
)}
</div>
</div>
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
{/* Content */}
<div ref={scrollContainerRef} className='mt-[24px] min-h-0 flex-1 overflow-y-auto'>
{permissionsLoading ? (
renderTableSkeleton()
) : files.length === 0 && failedFiles.length === 0 ? (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
No files uploaded yet
</div>
) : filteredFiles.length === 0 && failedFiles.length === 0 ? (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
No files found matching "{search}"
</div>
) : (
<Table className='table-fixed text-[13px]'>
<Table className='table-fixed text-[14px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[56%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[56%] px-[12px] py-[8px] text-[13px] text-[var(--text-secondary)]'>
Name
</TableHead>
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
Size
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
Uploaded
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
Actions
</TableHead>
</TableRow>
@@ -362,17 +362,17 @@ export function Files() {
<div className='flex min-w-0 items-center gap-[8px]'>
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-error)]' />
<span
className='min-w-0 truncate text-[14px] text-[var(--text-error)]'
className='min-w-0 truncate text-[15px] text-[var(--text-error)]'
title={fileName}
>
{truncateMiddle(fileName)}
</span>
</div>
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-error)]'>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-error)]'>
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-error)]'>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-error)]'>
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
@@ -398,17 +398,17 @@ export function Files() {
<button
onClick={() => handleDownload(file)}
disabled={downloadingFileId === file.id}
className='min-w-0 truncate text-left font-normal text-[14px] text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50'
className='min-w-0 truncate text-left font-normal text-[15px] text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50'
title={file.name}
>
{truncateMiddle(file.name)}
</button>
</div>
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
{formatFileSize(file.size)}
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
{formatDate(file.uploadedAt)}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
@@ -467,7 +467,7 @@ export function Files() {
<div className='h-[14px] w-[1.5px] bg-[var(--divider)]' />
<div className='flex items-center gap-[4px]'>
<Skeleton className='h-[12px] w-[40px] rounded-[2px]' />
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-tertiary)]'>/</span>
<Skeleton className='h-[12px] w-[32px] rounded-[2px]' />
</div>
</div>
@@ -483,16 +483,16 @@ export function Files() {
<div className='mt-auto flex flex-col gap-[8px] pt-[10px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{displayPlanName}
</span>
<div className='h-[14px] w-[1.5px] bg-[var(--divider)]' />
<div className='flex items-center gap-[4px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
<span className='font-medium text-[13px] text-[var(--text-tertiary)] tabular-nums'>
{formatStorageSize(storageInfo.usedBytes)}
</span>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>/</span>
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
<span className='font-medium text-[13px] text-[var(--text-tertiary)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-tertiary)] tabular-nums'>
{formatStorageSize(storageInfo.limitBytes)}
</span>
</div>

View File

@@ -0,0 +1,30 @@
'use client'
import { Files as FilesIcon } from 'lucide-react'
import { FileList } from '@/app/workspace/[workspaceId]/files/components/file-list'
export function Files() {
return (
<div className='flex h-full flex-1 flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'>
{/* Header */}
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#8B5CF6] bg-[#F5F3FF] dark:border-[#5B21B6] dark:bg-[#2E1065]'>
<FilesIcon className='h-[14px] w-[14px] text-[#8B5CF6] dark:text-[#A78BFA]' />
</div>
<h1 className='font-medium text-[18px]'>Files</h1>
</div>
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
Workspace files accessible across all workflows.
</p>
</div>
{/* Search, Actions, and Content */}
<FileList />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export default function FilesLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}

View File

@@ -0,0 +1,32 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Files } from './files'
interface FilesPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function FilesPage({ params }: FilesPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideFilesTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Files />
}

View File

@@ -1,5 +0,0 @@
'use client'
import { NextGlobalError } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
export default NextGlobalError

View File

@@ -0,0 +1,3 @@
export { MessageContent } from './message-content'
export { MothershipView } from './mothership-view'
export { UserInput } from './user-input'

View File

@@ -0,0 +1 @@
export { MessageContent } from './message-content'

View File

@@ -0,0 +1,136 @@
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import type { ContentBlock, ToolCallStatus } from '../../types'
const REMARK_PLUGINS = [remarkGfm]
const PROSE_CLASSES = cn(
'prose prose-base dark:prose-invert max-w-none font-body font-[380]',
'prose-headings:font-semibold prose-headings:tracking-[-0.01em] prose-headings:text-[var(--text-primary)]',
'prose-headings:mb-[12px] prose-headings:mt-[20px]',
'prose-p:text-[16px] prose-p:leading-[1.75] prose-p:tracking-[-0.015em] prose-p:text-[var(--text-primary)]',
'prose-p:mb-[8px]',
'prose-li:text-[16px] prose-li:leading-[1.75] prose-li:tracking-[-0.015em] prose-li:text-[var(--text-primary)]',
'prose-li:my-[4px]',
'prose-ul:my-[12px] prose-ol:my-[12px]',
'prose-strong:font-semibold prose-strong:text-[var(--text-primary)]',
'prose-a:text-[var(--brand-secondary)]',
'prose-code:rounded-[4px] prose-code:bg-[var(--surface-5)] prose-code:px-[5px] prose-code:py-[2px] prose-code:text-[13px] prose-code:font-mono prose-code:font-normal prose-code:text-[var(--text-primary)]',
'prose-pre:my-[14px] prose-pre:rounded-[8px] prose-pre:bg-[var(--surface-5)] prose-pre:text-[13px]',
'prose-hr:border-[var(--divider)]'
)
function formatToolName(name: string): string {
return name
.replace(/_v\d+$/, '')
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
interface TextSegment {
type: 'text'
content: string
}
interface ActionSegment {
type: 'action'
id: string
label: string
status: ToolCallStatus
}
type MessageSegment = TextSegment | ActionSegment
/**
* Flattens raw content blocks into a uniform list of text and action segments.
* Tool calls and subagents are treated identically as action items.
*/
function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegment[] {
const segments: MessageSegment[] = []
const lastSubagentIdx = blocks.findLastIndex((b) => b.type === 'subagent')
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
switch (block.type) {
case 'text': {
if (block.content?.trim()) {
const last = segments[segments.length - 1]
if (last?.type === 'text') {
last.content += block.content
} else {
segments.push({ type: 'text', content: block.content })
}
}
break
}
case 'subagent': {
if (block.content) {
segments.push({
type: 'action',
id: `subagent-${i}`,
label: block.content,
status: isStreaming && i === lastSubagentIdx ? 'executing' : 'success',
})
}
break
}
case 'tool_call': {
if (block.toolCall) {
segments.push({
type: 'action',
id: block.toolCall.id,
label: block.toolCall.displayTitle || formatToolName(block.toolCall.name),
status: block.toolCall.status,
})
}
break
}
}
}
return segments
}
interface MessageContentProps {
blocks: ContentBlock[]
fallbackContent: string
isStreaming: boolean
}
export function MessageContent({ blocks, fallbackContent, isStreaming }: MessageContentProps) {
const parsed = blocks.length > 0 ? parseBlocks(blocks, isStreaming) : []
const segments: MessageSegment[] =
parsed.length > 0
? parsed
: fallbackContent?.trim()
? [{ type: 'text' as const, content: fallbackContent }]
: []
if (segments.length === 0) return null
return (
<div className='space-y-[10px]'>
{segments.map((segment, i) => {
if (segment.type === 'text') {
return (
<div key={`text-${i}`} className={PROSE_CLASSES}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{segment.content}</ReactMarkdown>
</div>
)
}
return (
<div key={segment.id} className='font-base text-[13px] text-[var(--text-tertiary)]'>
{segment.label}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1 @@
export { MothershipView } from './mothership-view'

View File

@@ -0,0 +1,14 @@
'use client'
export function MothershipView() {
return (
<div className='flex h-full w-[480px] flex-shrink-0 flex-col border-[var(--border)] border-l'>
<div className='flex items-center border-[var(--border)] border-b px-[16px] py-[12px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Mothership</span>
</div>
<div className='flex flex-1 items-center justify-center'>
<span className='text-[13px] text-[var(--text-muted)]'>No artifacts yet</span>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { UserInput } from './user-input'

View File

@@ -0,0 +1,203 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowUp, Mic, Paperclip } from 'lucide-react'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useAnimatedPlaceholder } from '../../hooks'
const TEXTAREA_CLASSES = cn(
'm-0 box-border h-auto max-h-[30vh] min-h-[24px] w-full resize-none',
'overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent',
'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
'text-[var(--text-primary)] outline-none',
'placeholder:font-[350] placeholder:text-[var(--text-subtle)]',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
)
const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'
const SEND_BUTTON_ACTIVE =
'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, window.innerHeight * 0.3)}px`
}
interface UserInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
isSending: boolean
onStopGeneration: () => void
isInitialView?: boolean
}
export function UserInput({
value,
onChange,
onSubmit,
isSending,
onStopGeneration,
isInitialView = true,
}: UserInputProps) {
const animatedPlaceholder = useAnimatedPlaceholder()
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
const canSubmit = value.trim().length > 0 && !isSending
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognition | null>(null)
const prefixRef = useRef('')
useEffect(() => {
return () => {
recognitionRef.current?.abort()
}
}, [])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('button')) return
textareaRef.current?.focus()
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
},
[onSubmit]
)
const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop()
recognitionRef.current = null
setIsListening(false)
return
}
const w = window as Window & {
SpeechRecognition?: typeof SpeechRecognition
webkitSpeechRecognition?: typeof SpeechRecognition
}
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognitionAPI) return
prefixRef.current = value
const recognition = new SpeechRecognitionAPI()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = 'en-US'
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
const prefix = prefixRef.current
onChange(prefix ? `${prefix} ${transcript}` : transcript)
}
recognition.onend = () => {
if (recognitionRef.current === recognition) {
try {
recognition.start()
} catch {
recognitionRef.current = null
setIsListening(false)
}
}
}
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
if (e.error === 'aborted' || e.error === 'not-allowed') {
recognitionRef.current = null
setIsListening(false)
}
}
recognitionRef.current = recognition
recognition.start()
setIsListening(true)
}, [isListening, value, onChange])
return (
<div
onClick={handleContainerClick}
className={cn(
'mx-auto w-full max-w-[640px] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
isInitialView && 'shadow-sm'
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onInput={autoResizeTextarea}
placeholder={placeholder}
rows={1}
className={TEXTAREA_CLASSES}
/>
<div className='flex items-center justify-between'>
<div className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'>
<Paperclip
className='h-[14px] w-[14px] text-[var(--text-muted)] dark:text-[var(--text-secondary)]'
strokeWidth={2}
/>
</div>
<div className='flex items-center gap-[6px]'>
<button
type='button'
onClick={toggleListening}
className={cn(
'flex h-[28px] w-[28px] items-center justify-center rounded-full transition-colors',
isListening
? 'bg-red-500 text-white hover:bg-red-600'
: 'text-[var(--text-muted)] hover:bg-[#F7F7F7] dark:text-[var(--text-secondary)] dark:hover:bg-[#303030]'
)}
title={isListening ? 'Stop listening' : 'Voice input'}
>
<Mic className='h-[16px] w-[16px]' strokeWidth={2} />
</button>
{isSending ? (
<Button
onClick={onStopGeneration}
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
title='Stop generation'
>
<svg
className='block h-[14px] w-[14px] fill-white dark:fill-black'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
</svg>
</Button>
) : (
<Button
onClick={onSubmit}
disabled={!canSubmit}
className={cn(
SEND_BUTTON_BASE,
canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED
)}
>
<ArrowUp
className='block h-[16px] w-[16px] text-white dark:text-black'
strokeWidth={2.25}
/>
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { useCallback, useState } from 'react'
import { useParams } from 'next/navigation'
import { MessageContent, UserInput } from './components'
import { useChat } from './hooks'
interface HomeProps {
chatId?: string
}
export function Home({ chatId }: HomeProps = {}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const [inputValue, setInputValue] = useState('')
const { messages, isSending, sendMessage, stopGeneration, chatBottomRef } = useChat(
workspaceId,
chatId
)
const handleSubmit = useCallback(() => {
const trimmed = inputValue.trim()
if (!trimmed) return
setInputValue('')
sendMessage(trimmed)
}, [inputValue, sendMessage])
const hasMessages = messages.length > 0
if (!hasMessages) {
return (
<div className='flex h-full flex-col items-center justify-center bg-[#FCFCFC] px-[24px] dark:bg-[var(--surface-2)]'>
<h1 className='mb-[24px] font-[450] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
What do you want to do?
</h1>
<UserInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
/>
</div>
)
}
return (
<div className='flex h-full bg-[#FCFCFC] dark:bg-[var(--surface-2)]'>
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='min-h-0 flex-1 overflow-y-auto px-[24px] py-[16px]'>
<div className='mx-auto max-w-[640px] space-y-[16px]'>
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex justify-end'>
<div className='max-w-[80%] rounded-[16px] bg-[var(--surface-5)] px-[14px] py-[4px]'>
<p className='whitespace-pre-wrap font-[380] font-body text-[16px] text-[var(--text-primary)] leading-[1.75] tracking-[-0.015em]'>
{msg.content}
</p>
</div>
</div>
)
}
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
const isThisStreaming = isSending && msg === messages[messages.length - 1]
if (!hasBlocks && !msg.content && isThisStreaming) {
return (
<div key={msg.id} className='flex items-center gap-[6px] py-[8px]'>
<div className='h-[6px] w-[6px] animate-pulse rounded-full bg-[var(--text-tertiary)]' />
<span className='font-base text-[13px] text-[var(--text-tertiary)]'>
Thinking
</span>
</div>
)
}
if (!hasBlocks && !msg.content) return null
return (
<div key={msg.id}>
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
/>
</div>
)
})}
<div ref={chatBottomRef} />
</div>
</div>
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
<UserInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { useAnimatedPlaceholder } from './use-animated-placeholder'
export type { UseChatReturn } from './use-chat'
export { useChat } from './use-chat'

View File

@@ -0,0 +1,72 @@
import { useEffect, useRef, useState } from 'react'
const PLACEHOLDER_PREFIX = 'Ask Sim to '
const PLACEHOLDER_SUFFIXES = [
'respond to my emails...',
'find and track leads...',
'DM me Linear updates on Slack...',
'track GitHub commits...',
] as const
const TYPE_SPEED_MS = 60
const DELETE_SPEED_MS = 35
const PAUSE_AFTER_TYPING_MS = 2000
const PAUSE_AFTER_DELETING_MS = 400
export function useAnimatedPlaceholder(): string {
const [text, setText] = useState(PLACEHOLDER_PREFIX)
const stateRef = useRef({
suffixIndex: 0,
charIndex: 0,
phase: 'typing' as 'typing' | 'paused' | 'deleting' | 'waiting',
})
useEffect(() => {
const tick = () => {
const s = stateRef.current
const suffix = PLACEHOLDER_SUFFIXES[s.suffixIndex]
switch (s.phase) {
case 'typing': {
s.charIndex++
setText(PLACEHOLDER_PREFIX + suffix.slice(0, s.charIndex))
if (s.charIndex >= suffix.length) {
s.phase = 'paused'
return PAUSE_AFTER_TYPING_MS
}
return TYPE_SPEED_MS
}
case 'paused': {
s.phase = 'deleting'
return DELETE_SPEED_MS
}
case 'deleting': {
s.charIndex--
setText(PLACEHOLDER_PREFIX + suffix.slice(0, s.charIndex))
if (s.charIndex <= 0) {
s.phase = 'waiting'
return PAUSE_AFTER_DELETING_MS
}
return DELETE_SPEED_MS
}
case 'waiting': {
s.suffixIndex = (s.suffixIndex + 1) % PLACEHOLDER_SUFFIXES.length
s.charIndex = 0
s.phase = 'typing'
return TYPE_SPEED_MS
}
}
}
let timer: ReturnType<typeof setTimeout>
const schedule = () => {
const delay = tick()
timer = setTimeout(schedule, delay)
}
timer = setTimeout(schedule, TYPE_SPEED_MS)
return () => clearTimeout(timer)
}, [])
return text
}

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