mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
197 Commits
main
...
cursor/ger
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd44398dd8 | ||
|
|
898f8ce1c1 | ||
|
|
39334bdf7d | ||
|
|
a6d3b3a9ad | ||
|
|
8fd8b1a248 | ||
|
|
917af6d141 | ||
|
|
fe5f809e1a | ||
|
|
2f2c2b05e8 | ||
|
|
0c44332172 | ||
|
|
7c0cd36936 | ||
|
|
2788c68e45 | ||
|
|
cf9cc0377d | ||
|
|
a091149da4 | ||
|
|
64cedfcff7 | ||
|
|
15db69231f | ||
|
|
7b43091984 | ||
|
|
48f280427e | ||
|
|
1430eb66de | ||
|
|
2ace7252f9 | ||
|
|
bcdfc85ccb | ||
|
|
e921448bf2 | ||
|
|
71d8e227bd | ||
|
|
4593a8a471 | ||
|
|
301fdb94ff | ||
|
|
4afc3bbff8 | ||
|
|
76981c356f | ||
|
|
4c562c8e04 | ||
|
|
d4eb25df91 | ||
|
|
ac2af53884 | ||
|
|
016d353baf | ||
|
|
d9c1a53cad | ||
|
|
2bdc073d7b | ||
|
|
13d2a134d0 | ||
|
|
a61dc23d43 | ||
|
|
12c1ede336 | ||
|
|
627eaaf343 | ||
|
|
1dbfaa4d23 | ||
|
|
4946571922 | ||
|
|
6295fd1a11 | ||
|
|
de5faa5265 | ||
|
|
7d360649e9 | ||
|
|
1d955fc43a | ||
|
|
1def94392b | ||
|
|
77bd2553f2 | ||
|
|
8170488488 | ||
|
|
0b42e26f10 | ||
|
|
4b7a9b20c4 | ||
|
|
76486ebcc8 | ||
|
|
13d49da8bd | ||
|
|
05b8481a89 | ||
|
|
6690c55721 | ||
|
|
88a8c5f4a1 | ||
|
|
91ca6a531e | ||
|
|
2f45f935e4 | ||
|
|
2cb12de546 | ||
|
|
de32644940 | ||
|
|
8ff93fe842 | ||
|
|
0d9e04181f | ||
|
|
1324987def | ||
|
|
9c4abf7b9b | ||
|
|
00c9b72bdd | ||
|
|
386df7a062 | ||
|
|
0967755ad4 | ||
|
|
b50ccdf314 | ||
|
|
7247a5f4d8 | ||
|
|
875498c9aa | ||
|
|
3c196d180f | ||
|
|
2940de946c | ||
|
|
212f912827 | ||
|
|
9a505919b0 | ||
|
|
e6ca3b3311 | ||
|
|
b93c87c521 | ||
|
|
06a4a8162a | ||
|
|
96c2ae2c39 | ||
|
|
1e53d5748a | ||
|
|
6d803bcde2 | ||
|
|
bca131d597 | ||
|
|
0202c60d26 | ||
|
|
82ba3d7dd1 | ||
|
|
a0be3ff414 | ||
|
|
6cda9e60e8 | ||
|
|
a6abb9da67 | ||
|
|
777e4f57de | ||
|
|
695628de75 | ||
|
|
d71e4e51ea | ||
|
|
576d9d3025 | ||
|
|
43509374a2 | ||
|
|
0e7c719e82 | ||
|
|
226a3f64fb | ||
|
|
6c6b3579c9 | ||
|
|
a5b148e19e | ||
|
|
9665f49492 | ||
|
|
ff4b2f8c6a | ||
|
|
4735245c8f | ||
|
|
aac9e74283 | ||
|
|
d6b97fee08 | ||
|
|
280ac30d55 | ||
|
|
5c24d2422e | ||
|
|
9d001eaf70 | ||
|
|
3ce947566d | ||
|
|
17e1bb5331 | ||
|
|
443e15eb01 | ||
|
|
dbef14ba26 | ||
|
|
7140867ff9 | ||
|
|
73cd10ca21 | ||
|
|
a368827f1e | ||
|
|
eac8aca0c0 | ||
|
|
70c36cb7aa | ||
|
|
337154054e | ||
|
|
c6ac0b4445 | ||
|
|
b07925fcc0 | ||
|
|
08fb8c1651 | ||
|
|
37337aece5 | ||
|
|
da349176ab | ||
|
|
6f3559ce8f | ||
|
|
9a7b5ffe64 | ||
|
|
4ede071ecb | ||
|
|
161fb37244 | ||
|
|
d1575927a2 | ||
|
|
f1ec5fe824 | ||
|
|
a3b19fb32a | ||
|
|
21404d17e8 | ||
|
|
df7e731c9c | ||
|
|
4f4191fe1b | ||
|
|
b57636e5b1 | ||
|
|
38c9ecd259 | ||
|
|
fadda6aaef | ||
|
|
82f541e9de | ||
|
|
1339915957 | ||
|
|
7fafc00a07 | ||
|
|
fe5ab8aee8 | ||
|
|
b3a639a693 | ||
|
|
0249ca1480 | ||
|
|
553c376289 | ||
|
|
4622966643 | ||
|
|
e9550c624d | ||
|
|
1d48289c53 | ||
|
|
fce10241a5 | ||
|
|
ae080f125c | ||
|
|
0fb840c8fd | ||
|
|
2c20519bbd | ||
|
|
f3474b0c90 | ||
|
|
e07e3c34cc | ||
|
|
b2cc5b6738 | ||
|
|
0d2e6ff31d | ||
|
|
d49a2c1c25 | ||
|
|
8fa4745893 | ||
|
|
c168e36a05 | ||
|
|
9cc46ffa43 | ||
|
|
4fd0989264 | ||
|
|
cc5e592c46 | ||
|
|
7276136398 | ||
|
|
3ad7af4b97 | ||
|
|
3cb1768a44 | ||
|
|
11e6387a7d | ||
|
|
57a91027de | ||
|
|
49c29d5f7d | ||
|
|
843af915bc | ||
|
|
bb3e899f74 | ||
|
|
e47dcdcc43 | ||
|
|
3e6cf24762 | ||
|
|
90a12546b2 | ||
|
|
b6f8439267 | ||
|
|
4f74a8b845 | ||
|
|
f12d8f631f | ||
|
|
41f0957ccc | ||
|
|
7b813be1dd | ||
|
|
704fa16bb4 | ||
|
|
67f8a687f6 | ||
|
|
eccad2a8ce | ||
|
|
87f5c464d9 | ||
|
|
724aaa1432 | ||
|
|
3de3ef4786 | ||
|
|
743f048442 | ||
|
|
bbcf346df0 | ||
|
|
b9c3c2f78f | ||
|
|
d333307a17 | ||
|
|
134c4c4f2a | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
03908edcbb | ||
|
|
3112485c31 | ||
|
|
459c2930ae | ||
|
|
3338b25c30 | ||
|
|
4c3002f97d | ||
|
|
15ace5e63f | ||
|
|
632e0e0762 | ||
|
|
fdca73679d | ||
|
|
7599774974 | ||
|
|
471e58a2d0 | ||
|
|
231ddc59a0 | ||
|
|
b197f68828 | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
299
.claude/commands/add-connector.md
Normal file
299
.claude/commands/add-connector.md
Normal file
@@ -0,0 +1,299 @@
|
||||
---
|
||||
description: Add a knowledge base connector for syncing documents from an external source
|
||||
argument-hint: <service-name> [api-docs-url]
|
||||
---
|
||||
|
||||
# Add Connector Skill
|
||||
|
||||
You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base.
|
||||
|
||||
## Your Task
|
||||
|
||||
When the user asks you to create a connector:
|
||||
1. Use Context7 or WebFetch to read the service's API documentation
|
||||
2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth)
|
||||
3. Create the connector directory and config
|
||||
4. Register it in the connector registry
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Create files in `apps/sim/connectors/{service}/`:
|
||||
```
|
||||
connectors/{service}/
|
||||
├── index.ts # Barrel export
|
||||
└── {service}.ts # ConnectorConfig definition
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`):
|
||||
|
||||
```typescript
|
||||
type ConnectorAuthConfig =
|
||||
| { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] }
|
||||
| { mode: 'apiKey'; label?: string; placeholder?: string }
|
||||
```
|
||||
|
||||
### OAuth mode
|
||||
For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The `provider` must match an `OAuthService`. The modal shows a credential picker and handles token refresh automatically.
|
||||
|
||||
### API key mode
|
||||
For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`.
|
||||
|
||||
## ConnectorConfig Structure
|
||||
|
||||
### OAuth connector example
|
||||
|
||||
```typescript
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { {Service}Icon } from '@/components/icons'
|
||||
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
|
||||
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
||||
|
||||
const logger = createLogger('{Service}Connector')
|
||||
|
||||
export const {service}Connector: ConnectorConfig = {
|
||||
id: '{service}',
|
||||
name: '{Service}',
|
||||
description: 'Sync documents from {Service} into your knowledge base',
|
||||
version: '1.0.0',
|
||||
icon: {Service}Icon,
|
||||
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: '{service}', // Must match OAuthService in lib/oauth/types.ts
|
||||
requiredScopes: ['read:...'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
// Rendered dynamically by the add-connector modal UI
|
||||
// Supports 'short-input' and 'dropdown' types
|
||||
],
|
||||
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => {
|
||||
// Paginate via cursor, extract text, compute SHA-256 hash
|
||||
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
|
||||
},
|
||||
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
// Return ExternalDocument or null
|
||||
},
|
||||
|
||||
validateConfig: async (accessToken, sourceConfig) => {
|
||||
// Return { valid: true } or { valid: false, error: 'message' }
|
||||
},
|
||||
|
||||
// Optional: map source metadata to semantic tag keys (translated to slots by sync engine)
|
||||
mapTags: (metadata) => {
|
||||
// Return Record<string, unknown> with keys matching tagDefinitions[].id
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### API key connector example
|
||||
|
||||
```typescript
|
||||
export const {service}Connector: ConnectorConfig = {
|
||||
id: '{service}',
|
||||
name: '{Service}',
|
||||
description: 'Sync documents from {Service} into your knowledge base',
|
||||
version: '1.0.0',
|
||||
icon: {Service}Icon,
|
||||
|
||||
auth: {
|
||||
mode: 'apiKey',
|
||||
label: 'API Key', // Shown above the input field
|
||||
placeholder: 'Enter your {Service} API key', // Input placeholder
|
||||
},
|
||||
|
||||
configFields: [ /* ... */ ],
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ },
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ },
|
||||
validateConfig: async (accessToken, sourceConfig) => { /* ... */ },
|
||||
}
|
||||
```
|
||||
|
||||
## ConfigField Types
|
||||
|
||||
The add-connector modal renders these automatically — no custom UI needed.
|
||||
|
||||
```typescript
|
||||
// Text input
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'yoursite.example.com',
|
||||
required: true,
|
||||
}
|
||||
|
||||
// Dropdown (static options)
|
||||
{
|
||||
id: 'contentType',
|
||||
title: 'Content Type',
|
||||
type: 'dropdown',
|
||||
required: false,
|
||||
options: [
|
||||
{ label: 'Pages only', id: 'page' },
|
||||
{ label: 'Blog posts only', id: 'blogpost' },
|
||||
{ label: 'All content', id: 'all' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## ExternalDocument Shape
|
||||
|
||||
Every document returned from `listDocuments`/`getDocument` must include:
|
||||
|
||||
```typescript
|
||||
{
|
||||
externalId: string // Source-specific unique ID
|
||||
title: string // Document title
|
||||
content: string // Extracted plain text
|
||||
mimeType: 'text/plain' // Always text/plain (content is extracted)
|
||||
contentHash: string // SHA-256 of content (change detection)
|
||||
sourceUrl?: string // Link back to original (stored on document record)
|
||||
metadata?: Record<string, unknown> // Source-specific data (fed to mapTags)
|
||||
}
|
||||
```
|
||||
|
||||
## Content Hashing (Required)
|
||||
|
||||
The sync engine uses content hashes for change detection:
|
||||
|
||||
```typescript
|
||||
async function computeContentHash(content: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(content)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
```
|
||||
|
||||
## tagDefinitions — Declared Tag Definitions
|
||||
|
||||
Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
|
||||
On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names.
|
||||
|
||||
```typescript
|
||||
tagDefinitions: [
|
||||
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
|
||||
{ id: 'version', displayName: 'Version', fieldType: 'number' },
|
||||
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
|
||||
],
|
||||
```
|
||||
|
||||
Each entry has:
|
||||
- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`)
|
||||
- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified")
|
||||
- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from
|
||||
|
||||
Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`.
|
||||
The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`.
|
||||
|
||||
## mapTags — Metadata to Semantic Keys
|
||||
|
||||
Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set.
|
||||
The sync engine calls this automatically and translates semantic keys to actual DB slots
|
||||
using the `tagSlotMapping` stored on the connector.
|
||||
|
||||
Return keys must match the `id` values declared in `tagDefinitions`.
|
||||
|
||||
```typescript
|
||||
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
// Validate arrays before casting — metadata may be malformed
|
||||
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
|
||||
if (labels.length > 0) result.labels = labels.join(', ')
|
||||
|
||||
// Validate numbers — guard against NaN
|
||||
if (metadata.version != null) {
|
||||
const num = Number(metadata.version)
|
||||
if (!Number.isNaN(num)) result.version = num
|
||||
}
|
||||
|
||||
// Validate dates — guard against Invalid Date
|
||||
if (typeof metadata.lastModified === 'string') {
|
||||
const date = new Date(metadata.lastModified)
|
||||
if (!Number.isNaN(date.getTime())) result.lastModified = date
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## External API Calls — Use `fetchWithRetry`
|
||||
|
||||
All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged.
|
||||
|
||||
For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max).
|
||||
|
||||
```typescript
|
||||
import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils'
|
||||
|
||||
// Background sync — use defaults
|
||||
const response = await fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
// validateConfig — tighter retry budget
|
||||
const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS)
|
||||
```
|
||||
|
||||
## sourceUrl
|
||||
|
||||
If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path).
|
||||
|
||||
## Sync Engine Behavior (Do Not Modify)
|
||||
|
||||
The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It:
|
||||
1. Calls `listDocuments` with pagination until `hasMore` is false
|
||||
2. Compares `contentHash` to detect new/changed/unchanged documents
|
||||
3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically
|
||||
4. Handles soft-delete of removed documents
|
||||
5. Resolves access tokens automatically — OAuth tokens are refreshed, API keys are decrypted from the `encryptedApiKey` column
|
||||
|
||||
You never need to modify the sync engine when adding a connector.
|
||||
|
||||
## Icon
|
||||
|
||||
The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain.
|
||||
|
||||
If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG.
|
||||
|
||||
## Registering
|
||||
|
||||
Add one line to `apps/sim/connectors/registry.ts`:
|
||||
|
||||
```typescript
|
||||
import { {service}Connector } from '@/connectors/{service}'
|
||||
|
||||
export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
// ... existing connectors ...
|
||||
{service}: {service}Connector,
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
- **OAuth**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching
|
||||
- **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig
|
||||
- [ ] Created `connectors/{service}/index.ts` barrel export
|
||||
- [ ] **Auth configured correctly:**
|
||||
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
|
||||
- API key: `auth.label` and `auth.placeholder` set appropriately
|
||||
- [ ] `listDocuments` handles pagination and computes content hashes
|
||||
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
|
||||
- [ ] `metadata` includes source-specific data for tag mapping
|
||||
- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags`
|
||||
- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions)
|
||||
- [ ] `validateConfig` verifies the source is accessible
|
||||
- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`)
|
||||
- [ ] All optional config fields validated in `validateConfig`
|
||||
- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG)
|
||||
- [ ] Registered in `connectors/registry.ts`
|
||||
26
.cursor/rules/landing-seo-geo.mdc
Normal file
26
.cursor/rules/landing-seo-geo.mdc
Normal 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.
|
||||
@@ -5,62 +5,122 @@ globs: ["apps/sim/hooks/queries/**/*.ts"]
|
||||
|
||||
# React Query Patterns
|
||||
|
||||
All React Query hooks live in `hooks/queries/`.
|
||||
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
|
||||
|
||||
## Query Key Factory
|
||||
|
||||
Every query file defines a keys factory:
|
||||
Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation:
|
||||
|
||||
```typescript
|
||||
export const entityKeys = {
|
||||
all: ['entity'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
||||
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
|
||||
lists: () => [...entityKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
|
||||
details: () => [...entityKeys.all, 'detail'] as const,
|
||||
detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
|
||||
}
|
||||
```
|
||||
|
||||
Never use inline query keys — always use the factory.
|
||||
|
||||
## File Structure
|
||||
|
||||
```typescript
|
||||
// 1. Query keys factory
|
||||
// 2. Types (if needed)
|
||||
// 3. Private fetch functions
|
||||
// 3. Private fetch functions (accept signal parameter)
|
||||
// 4. Exported hooks
|
||||
```
|
||||
|
||||
## Query Hook
|
||||
|
||||
- Every `queryFn` must destructure and forward `signal` for request cancellation
|
||||
- Every query must have an explicit `staleTime`
|
||||
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
|
||||
|
||||
```typescript
|
||||
async function fetchEntities(workspaceId: string, signal?: AbortSignal) {
|
||||
const response = await fetch(`/api/entities?workspaceId=${workspaceId}`, { signal })
|
||||
if (!response.ok) throw new Error('Failed to fetch entities')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: entityKeys.list(workspaceId),
|
||||
queryFn: () => fetchEntities(workspaceId as string),
|
||||
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
placeholderData: keepPreviousData, // OK: workspaceId varies
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hook
|
||||
|
||||
- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
|
||||
- Invalidation must cover all affected query key prefixes (lists, details, related views)
|
||||
|
||||
```typescript
|
||||
export function useCreateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => { /* fetch POST */ },
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
For optimistic mutations, use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error, ensuring the cache is always reconciled with the server.
|
||||
|
||||
```typescript
|
||||
export function useUpdateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => { /* ... */ },
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
|
||||
const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
|
||||
queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic value */)
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
||||
|
||||
## useCallback Dependencies
|
||||
|
||||
Never include mutation objects (e.g., `createEntity`) in `useCallback` dependency arrays — the mutation object is not referentially stable and changes on every state update. The `.mutate()` and `.mutateAsync()` functions are stable in TanStack Query v5.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — causes unnecessary recreations
|
||||
const handler = useCallback(() => {
|
||||
createEntity.mutate(data)
|
||||
}, [createEntity]) // unstable reference
|
||||
|
||||
// ✓ Good — omit from deps, mutate is stable
|
||||
const handler = useCallback(() => {
|
||||
createEntity.mutate(data)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
- **Keys**: `entityKeys`
|
||||
- **Query hooks**: `useEntity`, `useEntityList`
|
||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
|
||||
- **Fetch functions**: `fetchEntity` (private)
|
||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`, `useDeleteEntity`
|
||||
- **Fetch functions**: `fetchEntity`, `fetchEntities` (private)
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,9 @@ bun-debug.log*
|
||||
**/standalone/
|
||||
sim-standalone.tar.gz
|
||||
|
||||
# redis
|
||||
dump.rdb
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
51
CLAUDE.md
51
CLAUDE.md
@@ -134,21 +134,64 @@ Use `devtools` middleware. Use `persist` only when data should survive reload wi
|
||||
|
||||
## React Query
|
||||
|
||||
All React Query hooks live in `hooks/queries/`.
|
||||
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
|
||||
|
||||
### Query Key Factory
|
||||
|
||||
Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation:
|
||||
|
||||
```typescript
|
||||
export const entityKeys = {
|
||||
all: ['entity'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
||||
lists: () => [...entityKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
|
||||
details: () => [...entityKeys.all, 'detail'] as const,
|
||||
detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
|
||||
}
|
||||
```
|
||||
|
||||
### Query Hooks
|
||||
|
||||
- Every `queryFn` must forward `signal` for request cancellation
|
||||
- Every query must have an explicit `staleTime`
|
||||
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
|
||||
|
||||
```typescript
|
||||
export function useEntityList(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: entityKeys.list(workspaceId),
|
||||
queryFn: () => fetchEntities(workspaceId as string),
|
||||
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
placeholderData: keepPreviousData, // OK: workspaceId varies
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation Hooks
|
||||
|
||||
- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
|
||||
- For optimistic updates: use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error
|
||||
- Don't include mutation objects in `useCallback` deps — `.mutate()` is stable in TanStack Query v5
|
||||
|
||||
```typescript
|
||||
export function useUpdateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => { /* ... */ },
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
|
||||
const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
|
||||
queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic */)
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -233,6 +233,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
lang={lang}
|
||||
breadcrumb={breadcrumbs}
|
||||
/>
|
||||
<style>{`#nd-page { grid-column: main-start / toc-end !important; max-width: 1400px !important; }`}</style>
|
||||
<DocsPage
|
||||
toc={data.toc}
|
||||
breadcrumb={{
|
||||
@@ -367,15 +368,17 @@ export async function generateMetadata(props: {
|
||||
return {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
keywords: [
|
||||
'AI workflow builder',
|
||||
'visual workflow editor',
|
||||
'AI automation',
|
||||
'workflow automation',
|
||||
'AI agents',
|
||||
'no-code AI',
|
||||
'drag and drop workflows',
|
||||
'agentic workforce',
|
||||
'AI agent platform',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI automation',
|
||||
'knowledge base',
|
||||
'AI integrations',
|
||||
data.title?.toLowerCase().split(' '),
|
||||
]
|
||||
.flat()
|
||||
@@ -385,7 +388,8 @@ export async function generateMetadata(props: {
|
||||
openGraph: {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
url: fullUrl,
|
||||
siteName: 'Sim Documentation',
|
||||
type: 'article',
|
||||
@@ -406,7 +410,8 @@ export async function generateMetadata(props: {
|
||||
card: 'summary_large_image',
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
images: [ogImageUrl],
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -552,16 +552,15 @@ video {
|
||||
/* API Reference Pages — Mintlify-style overrides */
|
||||
|
||||
/* OpenAPI pages: span main + TOC grid columns for wide two-column layout.
|
||||
The grid has columns: spacer | sidebar | main | toc | spacer.
|
||||
By spanning columns 3-4, the article fills both main and toc areas,
|
||||
while the grid structure stays identical to non-OpenAPI pages (no jitter). */
|
||||
Use named grid lines from grid-template-areas so this works regardless
|
||||
of whether the grid has 3 columns (production) or 5 columns (local dev). */
|
||||
#nd-page:has(.api-page-header) {
|
||||
grid-column: 3 / span 2 !important;
|
||||
grid-column: main-start / toc-end !important;
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
|
||||
/* Hide the empty TOC aside on OpenAPI pages so it doesn't overlay content */
|
||||
#nd-docs-layout:has(#nd-page .api-page-header) #nd-toc {
|
||||
#nd-docs-layout:has(#nd-page:has(.api-page-header)) #nd-toc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -590,44 +589,39 @@ video {
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Method badge pills in page content — colored background pills */
|
||||
#nd-page span.font-mono.font-medium[class*="text-green"] {
|
||||
background-color: rgb(220 252 231 / 0.6);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
/* Method badge pills — shared background colors (page + sidebar) */
|
||||
span.font-mono.font-medium[data-method="get"],
|
||||
span.font-mono.font-medium[data-method="head"],
|
||||
span.font-mono.font-medium[data-method="options"] {
|
||||
background-color: rgb(220 252 231 / 0.85);
|
||||
}
|
||||
html.dark #nd-page span.font-mono.font-medium[class*="text-green"] {
|
||||
html.dark span.font-mono.font-medium[data-method="get"],
|
||||
html.dark span.font-mono.font-medium[data-method="head"],
|
||||
html.dark span.font-mono.font-medium[data-method="options"] {
|
||||
background-color: rgb(34 197 94 / 0.15);
|
||||
}
|
||||
|
||||
#nd-page span.font-mono.font-medium[class*="text-blue"] {
|
||||
background-color: rgb(219 234 254 / 0.6);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
span.font-mono.font-medium[data-method="post"] {
|
||||
background-color: rgb(219 234 254 / 0.85);
|
||||
}
|
||||
html.dark #nd-page span.font-mono.font-medium[class*="text-blue"] {
|
||||
html.dark span.font-mono.font-medium[data-method="post"] {
|
||||
background-color: rgb(59 130 246 / 0.15);
|
||||
}
|
||||
|
||||
#nd-page span.font-mono.font-medium[class*="text-orange"] {
|
||||
background-color: rgb(255 237 213 / 0.6);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
span.font-mono.font-medium[data-method="put"] {
|
||||
background-color: rgb(254 249 195 / 0.85);
|
||||
}
|
||||
html.dark #nd-page span.font-mono.font-medium[class*="text-orange"] {
|
||||
html.dark span.font-mono.font-medium[data-method="put"] {
|
||||
background-color: rgb(234 179 8 / 0.15);
|
||||
}
|
||||
span.font-mono.font-medium[data-method="patch"] {
|
||||
background-color: rgb(255 237 213 / 0.85);
|
||||
}
|
||||
html.dark span.font-mono.font-medium[data-method="patch"] {
|
||||
background-color: rgb(249 115 22 / 0.15);
|
||||
}
|
||||
|
||||
#nd-page span.font-mono.font-medium[class*="text-red"] {
|
||||
background-color: rgb(254 226 226 / 0.6);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
span.font-mono.font-medium[data-method="delete"] {
|
||||
background-color: rgb(254 226 226 / 0.85);
|
||||
}
|
||||
html.dark #nd-page span.font-mono.font-medium[class*="text-red"] {
|
||||
html.dark span.font-mono.font-medium[data-method="delete"] {
|
||||
background-color: rgb(239 68 68 / 0.15);
|
||||
}
|
||||
|
||||
@@ -635,52 +629,31 @@ html.dark #nd-page span.font-mono.font-medium[class*="text-red"] {
|
||||
#nd-sidebar a:has(span.font-mono.font-medium) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 6px;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Sidebar method badges — ensure proper inline flex display */
|
||||
/* Sidebar method badges — fixed-width for right-aligned labels */
|
||||
#nd-sidebar a span.font-mono.font-medium {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
font-size: 10px !important;
|
||||
width: 2.625rem;
|
||||
font-size: 0.625rem !important;
|
||||
line-height: 1 !important;
|
||||
padding: 2.5px 4px;
|
||||
border-radius: 3px;
|
||||
padding: 0.15625rem 0.25rem;
|
||||
border-radius: 0.1875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sidebar GET badges */
|
||||
#nd-sidebar a span.font-mono.font-medium[class*="text-green"] {
|
||||
background-color: rgb(220 252 231 / 0.6);
|
||||
}
|
||||
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-green"] {
|
||||
background-color: rgb(34 197 94 / 0.15);
|
||||
}
|
||||
|
||||
/* Sidebar POST badges */
|
||||
#nd-sidebar a span.font-mono.font-medium[class*="text-blue"] {
|
||||
background-color: rgb(219 234 254 / 0.6);
|
||||
}
|
||||
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-blue"] {
|
||||
background-color: rgb(59 130 246 / 0.15);
|
||||
}
|
||||
|
||||
/* Sidebar PUT badges */
|
||||
#nd-sidebar a span.font-mono.font-medium[class*="text-orange"] {
|
||||
background-color: rgb(255 237 213 / 0.6);
|
||||
}
|
||||
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-orange"] {
|
||||
background-color: rgb(249 115 22 / 0.15);
|
||||
}
|
||||
|
||||
/* Sidebar DELETE badges */
|
||||
#nd-sidebar a span.font-mono.font-medium[class*="text-red"] {
|
||||
background-color: rgb(254 226 226 / 0.6);
|
||||
}
|
||||
html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-red"] {
|
||||
background-color: rgb(239 68 68 / 0.15);
|
||||
/* Footer navigation method badges — pill styling to match sidebar */
|
||||
#nd-page span.font-mono.font-medium[data-method] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.625rem !important;
|
||||
line-height: 1 !important;
|
||||
padding: 0.15625rem 0.375rem;
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
/* Code block containers — match regular docs styling */
|
||||
@@ -740,8 +713,25 @@ html.dark
|
||||
font-size: 0.6875rem !important;
|
||||
letter-spacing: 0.025em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.125rem 0.5rem !important;
|
||||
border-radius: 0.375rem !important;
|
||||
}
|
||||
/* POST — softer blue */
|
||||
/* Path bar per-method colors (fumadocs renders these, so we match by class) */
|
||||
/* GET */
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-green"] {
|
||||
color: rgb(22 163 74) !important;
|
||||
background-color: rgb(220 252 231 / 0.7) !important;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-green"] {
|
||||
color: rgb(74 222 128) !important;
|
||||
background-color: rgb(34 197 94 / 0.15) !important;
|
||||
}
|
||||
/* POST */
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-blue"] {
|
||||
@@ -755,19 +745,47 @@ html.dark
|
||||
color: rgb(96 165 250) !important;
|
||||
background-color: rgb(59 130 246 / 0.15) !important;
|
||||
}
|
||||
/* GET — softer green */
|
||||
/* PUT */
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-green"] {
|
||||
color: rgb(22 163 74) !important;
|
||||
background-color: rgb(220 252 231 / 0.7) !important;
|
||||
span.font-mono.font-medium[class*="text-yellow"] {
|
||||
color: rgb(161 98 7) !important;
|
||||
background-color: rgb(254 249 195 / 0.7) !important;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-green"] {
|
||||
color: rgb(74 222 128) !important;
|
||||
background-color: rgb(34 197 94 / 0.15) !important;
|
||||
span.font-mono.font-medium[class*="text-yellow"] {
|
||||
color: rgb(250 204 21) !important;
|
||||
background-color: rgb(234 179 8 / 0.15) !important;
|
||||
}
|
||||
/* PATCH */
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-orange"] {
|
||||
color: rgb(194 65 12) !important;
|
||||
background-color: rgb(255 237 213 / 0.7) !important;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-orange"] {
|
||||
color: rgb(251 146 60) !important;
|
||||
background-color: rgb(249 115 22 / 0.15) !important;
|
||||
}
|
||||
/* DELETE */
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-red"] {
|
||||
color: rgb(185 28 28) !important;
|
||||
background-color: rgb(254 226 226 / 0.7) !important;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.flex.flex-row.items-center.rounded-xl.border.not-prose
|
||||
span.font-mono.font-medium[class*="text-red"] {
|
||||
color: rgb(248 113 113) !important;
|
||||
background-color: rgb(239 68 68 / 0.15) !important;
|
||||
}
|
||||
|
||||
/* Path text inside method+path bar — monospace, bright like Gumloop */
|
||||
@@ -966,17 +984,17 @@ html.dark .response-section-dropdown-item:hover {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
/* Type badge — order 2, grey pill like Mintlify */
|
||||
/* Type badge — order 2, grey pill */
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground {
|
||||
order: 2;
|
||||
background-color: rgb(240 240 243);
|
||||
color: rgb(100 100 110);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: rgb(241 245 249);
|
||||
color: rgb(71 85 105);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -984,8 +1002,8 @@ html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground {
|
||||
background-color: rgb(39 39 42);
|
||||
color: rgb(212 212 216);
|
||||
background-color: rgb(51 51 56);
|
||||
color: rgb(212 212 220);
|
||||
}
|
||||
|
||||
/* Hide the "*" inside the name span — we'll add "required" as a ::after on the flex row */
|
||||
@@ -993,26 +1011,26 @@ html.dark
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Required badge — order 3, light red pill */
|
||||
/* Required badge — order 3, red pill */
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after {
|
||||
content: "required";
|
||||
order: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: rgb(254 235 235);
|
||||
color: rgb(220 38 38);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: rgb(254 226 226);
|
||||
color: rgb(185 28 28);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after {
|
||||
background-color: rgb(127 29 29 / 0.2);
|
||||
background-color: rgb(153 27 27 / 0.3);
|
||||
color: rgb(252 165 165);
|
||||
}
|
||||
|
||||
@@ -1054,12 +1072,12 @@ html.dark
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground::after {
|
||||
content: "string";
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
background-color: rgb(240 240 243);
|
||||
color: rgb(100 100 110);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: rgb(241 245 249);
|
||||
color: rgb(71 85 105);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1069,8 +1087,8 @@ html.dark
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span.text-sm.font-mono.text-fd-muted-foreground::after {
|
||||
background-color: rgb(39 39 42);
|
||||
color: rgb(212 212 216);
|
||||
background-color: rgb(51 51 56);
|
||||
color: rgb(212 212 220);
|
||||
}
|
||||
|
||||
/* "header" badge via ::before on the auth flex row */
|
||||
@@ -1079,12 +1097,12 @@ html.dark
|
||||
order: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: rgb(240 240 243);
|
||||
color: rgb(100 100 110);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: rgb(241 245 249);
|
||||
color: rgb(71 85 105);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -1092,22 +1110,22 @@ html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose::before {
|
||||
background-color: rgb(39 39 42);
|
||||
color: rgb(212 212 216);
|
||||
background-color: rgb(51 51 56);
|
||||
color: rgb(212 212 220);
|
||||
}
|
||||
|
||||
/* "required" badge via ::after on the auth flex row — light red pill */
|
||||
/* "required" badge via ::after on the auth flex row — red pill */
|
||||
#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::after {
|
||||
content: "required";
|
||||
order: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: rgb(254 235 235);
|
||||
color: rgb(220 38 38);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: rgb(254 226 226);
|
||||
color: rgb(185 28 28);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -1115,7 +1133,7 @@ html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
div.my-4
|
||||
> .flex.flex-wrap.items-center.gap-3.not-prose::after {
|
||||
background-color: rgb(127 29 29 / 0.2);
|
||||
background-color: rgb(153 27 27 / 0.3);
|
||||
color: rgb(252 165 165);
|
||||
}
|
||||
|
||||
@@ -1168,12 +1186,12 @@ html.dark #nd-page:has(.api-page-header) .text-sm.border-t {
|
||||
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > button,
|
||||
#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > span:has(> button) {
|
||||
order: 2;
|
||||
background-color: rgb(240 240 243);
|
||||
color: rgb(100 100 110);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: rgb(241 245 249);
|
||||
color: rgb(71 85 105);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.125rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -1182,8 +1200,8 @@ html.dark
|
||||
#nd-page:has(.api-page-header)
|
||||
.flex.flex-wrap.items-center.gap-3.not-prose
|
||||
> span:has(> button) {
|
||||
background-color: rgb(39 39 42);
|
||||
color: rgb(212 212 216);
|
||||
background-color: rgb(51 51 56);
|
||||
color: rgb(212 212 220);
|
||||
}
|
||||
|
||||
/* Section headings (Authorization, Path Parameters, etc.) — consistent top spacing */
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"(generated)/workflows",
|
||||
"(generated)/logs",
|
||||
"(generated)/usage",
|
||||
"(generated)/audit-logs"
|
||||
"(generated)/audit-logs",
|
||||
"(generated)/tables",
|
||||
"(generated)/files",
|
||||
"(generated)/knowledge-bases"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -190,13 +190,8 @@ console.log(`${processedItems} gültige Elemente verarbeitet`);
|
||||
|
||||
### Einschränkungen
|
||||
|
||||
<Callout type="warning">
|
||||
Container-Blöcke (Schleifen und Parallele) können nicht ineinander verschachtelt werden. Das bedeutet:
|
||||
- Du kannst keinen Schleifenblock in einen anderen Schleifenblock platzieren
|
||||
- Du kannst keinen Parallel-Block in einen Schleifenblock platzieren
|
||||
- Du kannst keinen Container-Block in einen anderen Container-Block platzieren
|
||||
|
||||
Wenn du mehrdimensionale Iterationen benötigst, erwäge eine Umstrukturierung deines Workflows, um sequentielle Schleifen zu verwenden oder Daten in Stufen zu verarbeiten.
|
||||
<Callout type="info">
|
||||
Container-Blöcke (Schleifen und Parallele) unterstützen Verschachtelung. Du kannst Schleifen in Schleifen, Parallele in Schleifen und jede Kombination von Container-Blöcken platzieren, um komplexe mehrdimensionale Workflows zu erstellen.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -142,11 +142,8 @@ Jede parallele Instanz läuft unabhängig:
|
||||
|
||||
### Einschränkungen
|
||||
|
||||
<Callout type="warning">
|
||||
Container-Blöcke (Schleifen und Parallele) können nicht ineinander verschachtelt werden. Das bedeutet:
|
||||
- Sie können keinen Schleifenblock in einen Parallelblock platzieren
|
||||
- Sie können keinen weiteren Parallelblock in einen Parallelblock platzieren
|
||||
- Sie können keinen Container-Block in einen anderen Container-Block platzieren
|
||||
<Callout type="info">
|
||||
Container-Blöcke (Schleifen und Parallele) unterstützen Verschachtelung. Sie können Parallele in Parallele, Schleifen in Parallele und jede Kombination von Container-Blöcken platzieren, um komplexe mehrdimensionale Workflows zu erstellen.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"(generated)/workflows",
|
||||
"(generated)/logs",
|
||||
"(generated)/usage",
|
||||
"(generated)/audit-logs"
|
||||
"(generated)/audit-logs",
|
||||
"(generated)/tables",
|
||||
"(generated)/files",
|
||||
"(generated)/knowledge-bases"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -184,13 +184,8 @@ Variables (i=0) → Loop (While i<10) → Agent (Process) → Variables (i++)
|
||||
|
||||
### Limitations
|
||||
|
||||
<Callout type="warning">
|
||||
Container blocks (Loops and Parallels) cannot be nested inside each other. This means:
|
||||
- You cannot place a Loop block inside another Loop block
|
||||
- You cannot place a Parallel block inside a Loop block
|
||||
- You cannot place any container block inside another container block
|
||||
|
||||
If you need multi-dimensional iteration, consider restructuring your workflow to use sequential loops or process data in stages.
|
||||
<Callout type="info">
|
||||
Container blocks (Loops and Parallels) support nesting. You can place loops inside loops, parallels inside loops, and any combination of container blocks to build complex multi-dimensional workflows.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -148,11 +148,8 @@ Each parallel instance runs independently:
|
||||
|
||||
### Limitations
|
||||
|
||||
<Callout type="warning">
|
||||
Container blocks (Loops and Parallels) cannot be nested inside each other. This means:
|
||||
- You cannot place a Loop block inside a Parallel block
|
||||
- You cannot place another Parallel block inside a Parallel block
|
||||
- You cannot place any container block inside another container block
|
||||
<Callout type="info">
|
||||
Container blocks (Loops and Parallels) support nesting. You can place parallels inside parallels, loops inside parallels, and any combination of container blocks to build complex multi-dimensional workflows.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
143
apps/docs/content/docs/en/knowledgebase/connectors.mdx
Normal file
143
apps/docs/content/docs/en/knowledgebase/connectors.mdx
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: Connectors
|
||||
description: Automatically sync documents from external sources into your knowledge base
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
|
||||
Connectors let you pull documents directly from external services into your knowledge base. Instead of manually uploading files, a connector continuously syncs content from sources like Notion, Google Drive, GitHub, Slack, and more — keeping your knowledge base up to date automatically.
|
||||
|
||||
## Available Connectors
|
||||
|
||||
Sim ships with 20+ built-in connectors spanning productivity tools, cloud storage, development platforms, and more.
|
||||
|
||||
| Category | Connectors |
|
||||
|----------|-----------|
|
||||
| **Productivity** | Notion, Confluence, Asana, Linear, Jira |
|
||||
| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint |
|
||||
| **Documents** | Google Docs, WordPress, Webflow |
|
||||
| **Development** | GitHub |
|
||||
| **Communication** | Slack |
|
||||
| **CRM** | HubSpot, Salesforce |
|
||||
| **Data** | Airtable |
|
||||
| **Note-taking** | Evernote, Obsidian |
|
||||
| **Meetings** | Fireflies |
|
||||
|
||||
## Adding a Connector
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Select a source
|
||||
|
||||
Open a knowledge base and click **Add Connector**. You'll see the full list of available connectors — pick the service you want to sync from.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Authenticate
|
||||
|
||||
Most connectors use **OAuth** — select an existing credential from the dropdown, or click **Connect new account** to authorize through the service's login flow. Tokens are refreshed automatically, so you won't need to re-authenticate unless you revoke access.
|
||||
|
||||
A few connectors (Evernote, Obsidian, Fireflies) use **API keys** instead. Paste your key or developer token directly, and it will be stored securely.
|
||||
|
||||
<Callout type="info">
|
||||
If you rotate an API key in the external service, you'll need to update it in Sim as well. OAuth tokens are refreshed automatically, but API keys are not.
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure
|
||||
|
||||
Each connector has its own configuration fields that control what gets synced. Some examples:
|
||||
|
||||
- **Notion**: Choose between syncing an entire workspace, a specific database, or a single page tree
|
||||
- **GitHub**: Specify a repository, branch, and optional file extension filter
|
||||
- **Confluence**: Enter your Atlassian domain and optionally filter by space key or content type
|
||||
- **Obsidian**: Provide your vault URL and optionally restrict to a folder path
|
||||
|
||||
All configuration is validated when you save — if a repository doesn't exist or a domain is unreachable, you'll get an immediate error.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Choose sync frequency
|
||||
|
||||
Select how often the connector should re-sync:
|
||||
|
||||
| Frequency | Description |
|
||||
|-----------|-------------|
|
||||
| Every hour | Best for fast-moving sources |
|
||||
| Every 6 hours | Good balance for most use cases |
|
||||
| **Daily** (default) | Suitable for content that changes infrequently |
|
||||
| Weekly | For stable, rarely-updated sources |
|
||||
| Manual only | Sync only when you trigger it |
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure metadata tags (optional)
|
||||
|
||||
If the connector supports metadata tags, you'll see checkboxes for each tag type (e.g., Labels, Last Modified, Notebook). All are enabled by default — uncheck any you don't need.
|
||||
|
||||
See the [Metadata Tags](#metadata-tags) section below for details.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Connect & Sync
|
||||
|
||||
Click **Connect & Sync** to save the connector and trigger the first sync immediately. Documents will begin appearing in your knowledge base as they are processed.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## How Syncing Works
|
||||
|
||||
On each sync, the connector fetches documents from the external service and compares them against what's already in your knowledge base. Only documents that have actually changed are reprocessed — new content is added, updated content is re-chunked and re-embedded, and documents that no longer exist in the source are removed.
|
||||
|
||||
This means syncing is efficient even for large document sets. A connector with thousands of documents will only do meaningful work when something changes.
|
||||
|
||||
### Handling Failures
|
||||
|
||||
If a single document fails to fetch (e.g., due to a permission issue or timeout), the sync continues with the remaining documents. The failed document will be retried on the next sync cycle.
|
||||
|
||||
If an entire sync fails (e.g., the service is down or credentials expired), the connector automatically backs off and retries later. The backoff resets as soon as a sync succeeds.
|
||||
|
||||
## Metadata Tags
|
||||
|
||||
Connectors can automatically populate [tags](/docs/knowledgebase/tags) with metadata from the source, letting you filter documents in the Knowledge block based on information from the external service.
|
||||
|
||||
For example, a Notion connector might tag documents with their **Labels**, **Last Modified** date, and **Created** date. A GitHub connector might tag documents with their **Repository** and **File Path**. This metadata becomes available for [tag-based filtering](/docs/knowledgebase/tags) in your workflows.
|
||||
|
||||
### Opting Out
|
||||
|
||||
You can disable specific metadata tags during connector setup. Disabled tags won't be populated, leaving those tag slots available for other connectors or manual tagging.
|
||||
|
||||
<Callout type="info">
|
||||
Tag slots are shared across all documents in a knowledge base. If you have multiple connectors, each one's metadata tags draw from the same pool of available slots.
|
||||
</Callout>
|
||||
|
||||
## Excluding Documents
|
||||
|
||||
You can manually exclude specific documents from a connector's sync. Excluded documents are skipped on every subsequent sync, even if they change in the source. This is useful for filtering out templates, drafts, or other content you don't want in your knowledge base.
|
||||
|
||||
## Source Links
|
||||
|
||||
Every synced document retains a link back to the original in the external service. This lets you trace any knowledge base document to its source — whether that's a Notion page, a GitHub file, a Confluence article, or a Slack conversation.
|
||||
|
||||
## Multiple Connectors
|
||||
|
||||
You can add multiple connectors to a single knowledge base. For example, you might sync internal documentation from Confluence alongside code from GitHub and meeting notes from Fireflies — all searchable together through the Knowledge block.
|
||||
|
||||
Each connector manages its own documents independently. Metadata tag slots are shared across the knowledge base, so keep an eye on slot usage if you're combining several connectors that each populate tags.
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- **Internal knowledge base**: Sync your team's Notion workspace and Confluence spaces so AI agents can answer questions about internal processes, policies, and documentation
|
||||
- **Customer support**: Connect HubSpot or Salesforce alongside your help docs from WordPress or Google Docs to give support agents full context on customers and product information
|
||||
- **Engineering assistant**: Sync a GitHub repository and Jira or Linear issues so an AI agent can reference code, specs, and ticket history when answering developer questions
|
||||
- **Meeting intelligence**: Pull in Fireflies transcripts alongside Slack conversations to build a searchable archive of decisions and discussions
|
||||
- **Research and notes**: Sync Evernote notebooks or an Obsidian vault to make your personal notes available to AI workflows
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Knowledgebase",
|
||||
"pages": ["index", "tags"]
|
||||
"pages": ["index", "connectors", "tags"]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
@@ -183,13 +183,8 @@ while (count < items.length) {
|
||||
|
||||
### Limitaciones
|
||||
|
||||
<Callout type="warning">
|
||||
Los bloques contenedores (Bucles y Paralelos) no pueden anidarse unos dentro de otros. Esto significa:
|
||||
- No puedes colocar un bloque de Bucle dentro de otro bloque de Bucle
|
||||
- No puedes colocar un bloque Paralelo dentro de un bloque de Bucle
|
||||
- No puedes colocar ningún bloque contenedor dentro de otro bloque contenedor
|
||||
|
||||
Si necesitas iteración multidimensional, considera reestructurar tu flujo de trabajo para usar bucles secuenciales o procesar datos por etapas.
|
||||
<Callout type="info">
|
||||
Los bloques contenedores (Bucles y Paralelos) admiten anidamiento. Puedes colocar bucles dentro de bucles, paralelos dentro de bucles, y cualquier combinación de bloques contenedores para construir flujos de trabajo multidimensionales complejos.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -117,11 +117,8 @@ Cada instancia paralela se ejecuta de forma independiente:
|
||||
|
||||
### Limitaciones
|
||||
|
||||
<Callout type="warning">
|
||||
Los bloques contenedores (Bucles y Paralelos) no pueden anidarse unos dentro de otros. Esto significa:
|
||||
- No puedes colocar un bloque de Bucle dentro de un bloque Paralelo
|
||||
- No puedes colocar otro bloque Paralelo dentro de un bloque Paralelo
|
||||
- No puedes colocar ningún bloque contenedor dentro de otro bloque contenedor
|
||||
<Callout type="info">
|
||||
Los bloques contenedores (Bucles y Paralelos) admiten anidamiento. Puedes colocar paralelos dentro de paralelos, bucles dentro de paralelos, y cualquier combinación de bloques contenedores para construir flujos de trabajo multidimensionales complejos.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -190,13 +190,8 @@ return results;
|
||||
|
||||
### Limitations
|
||||
|
||||
<Callout type="warning">
|
||||
Les blocs conteneurs (Boucles et Parallèles) ne peuvent pas être imbriqués les uns dans les autres. Cela signifie :
|
||||
- Vous ne pouvez pas placer un bloc Boucle à l'intérieur d'un autre bloc Boucle
|
||||
- Vous ne pouvez pas placer un bloc Parallèle à l'intérieur d'un bloc Boucle
|
||||
- Vous ne pouvez pas placer un bloc conteneur à l'intérieur d'un autre bloc conteneur
|
||||
|
||||
Si vous avez besoin d'une itération multidimensionnelle, envisagez de restructurer votre flux de travail pour utiliser des boucles séquentielles ou traiter les données par étapes.
|
||||
<Callout type="info">
|
||||
Les blocs conteneurs (Boucles et Parallèles) prennent en charge l'imbrication. Vous pouvez placer des boucles dans des boucles, des parallèles dans des boucles, et toute combinaison de blocs conteneurs pour construire des flux de travail multidimensionnels complexes.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -117,11 +117,8 @@ Chaque instance parallèle s'exécute indépendamment :
|
||||
|
||||
### Limitations
|
||||
|
||||
<Callout type="warning">
|
||||
Les blocs conteneurs (Boucles et Parallèles) ne peuvent pas être imbriqués les uns dans les autres. Cela signifie :
|
||||
- Vous ne pouvez pas placer un bloc de Boucle à l'intérieur d'un bloc Parallèle
|
||||
- Vous ne pouvez pas placer un autre bloc Parallèle à l'intérieur d'un bloc Parallèle
|
||||
- Vous ne pouvez pas placer un bloc conteneur à l'intérieur d'un autre bloc conteneur
|
||||
<Callout type="info">
|
||||
Les blocs conteneurs (Boucles et Parallèles) prennent en charge l'imbrication. Vous pouvez placer des parallèles dans des parallèles, des boucles dans des parallèles, et toute combinaison de blocs conteneurs pour construire des flux de travail multidimensionnels complexes.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -198,13 +198,8 @@ while (counter < items.length && !foundTarget) {
|
||||
|
||||
### 制限事項
|
||||
|
||||
<Callout type="warning">
|
||||
コンテナブロック(ループと並列)は互いに入れ子にすることができません。つまり:
|
||||
- ループブロックを別のループブロック内に配置することはできません
|
||||
- 並列ブロックをループブロック内に配置することはできません
|
||||
- どのコンテナブロックも別のコンテナブロック内に配置することはできません
|
||||
|
||||
多次元反復が必要な場合は、順次ループを使用するか、データを段階的に処理するようにワークフローを再構成することを検討してください。
|
||||
<Callout type="info">
|
||||
コンテナブロック(ループと並列)はネストをサポートしています。ループの中にループを、ループの中に並列を、そして任意のコンテナブロックの組み合わせで複雑な多次元ワークフローを構築できます。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -117,11 +117,8 @@ Parallel (["gpt-4o", "claude-3.7-sonnet", "gemini-2.5-pro"]) → Agent → Evalu
|
||||
|
||||
### 制限事項
|
||||
|
||||
<Callout type="warning">
|
||||
コンテナブロック(ループと並列)は互いにネストできません。つまり:
|
||||
- 並列ブロック内にループブロックを配置できません
|
||||
- 並列ブロック内に別の並列ブロックを配置できません
|
||||
- どのコンテナブロック内にも別のコンテナブロックを配置できません
|
||||
<Callout type="info">
|
||||
コンテナブロック(ループと並列)はネストをサポートしています。並列の中に並列を、並列の中にループを、そして任意のコンテナブロックの組み合わせで複雑な多次元ワークフローを構築できます。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -160,13 +160,8 @@ Variables (i=0) → Loop (While i<10) → Agent (Process) → Variables (i++)
|
||||
|
||||
### 限制
|
||||
|
||||
<Callout type="warning">
|
||||
容器块(循环和并行)不能嵌套在彼此内部。这意味着:
|
||||
- 您不能将一个循环块放入另一个循环块中
|
||||
- 您不能将一个并行块放入循环块中
|
||||
- 您不能将任何容器块放入另一个容器块中
|
||||
|
||||
如果您需要多维迭代,请考虑重构您的工作流以使用顺序循环或分阶段处理数据。
|
||||
<Callout type="info">
|
||||
容器块(循环和并行)支持嵌套。您可以将循环放入循环中、将并行放入循环中,以及任意组合容器块来构建复杂的多维工作流。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -121,11 +121,8 @@ const allResults = input.parallel.results;
|
||||
|
||||
### 限制
|
||||
|
||||
<Callout type="warning">
|
||||
容器块(循环和并行)不能嵌套在彼此内部。这意味着:
|
||||
- 您不能在并行块中放置循环块
|
||||
- 您不能在并行块中放置另一个并行块
|
||||
- 您不能在一个容器块中放置另一个容器块
|
||||
<Callout type="info">
|
||||
容器块(循环和并行)支持嵌套。您可以将并行放入并行中、将循环放入并行中,以及任意组合容器块来构建复杂的多维工作流。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -62,7 +62,10 @@ function openapiPluginBadgeLeft() {
|
||||
null,
|
||||
createElement(
|
||||
'span',
|
||||
{ className: `font-mono font-medium me-1.5 text-[10px] text-nowrap ${colorClass}` },
|
||||
{
|
||||
className: `font-mono font-medium me-1.5 text-[10px] text-nowrap ${colorClass}`,
|
||||
'data-method': method.toLowerCase(),
|
||||
},
|
||||
method
|
||||
),
|
||||
node.name
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@
|
||||
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@sim/db": "workspace:*",
|
||||
|
||||
37
apps/sim/app/(auth)/auth-layout-client.tsx
Normal file
37
apps/sim/app/(auth)/auth-layout-client.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
function isColorDark(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
|
||||
|
||||
if (brandBackground && isColorDark(brandBackground)) {
|
||||
document.body.classList.add('auth-dark-bg')
|
||||
} else {
|
||||
document.body.classList.remove('auth-dark-bg')
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,10 @@
|
||||
'use client'
|
||||
import type { Metadata } from 'next'
|
||||
import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
// Helper to detect if a color is dark
|
||||
function isColorDark(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
// Check if brand background is dark and add class accordingly
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
|
||||
|
||||
if (brandBackground && isColorDark(brandBackground)) {
|
||||
document.body.classList.add('auth-dark-bg')
|
||||
} else {
|
||||
document.body.classList.remove('auth-dark-bg')
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
{/* Header - Nav handles all conditional logic */}
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
return <AuthLayoutClient>{children}</AuthLayoutClient>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import LoginForm from '@/app/(auth)/login/login-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Log In',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function LoginPage() {
|
||||
|
||||
@@ -1,117 +1,8 @@
|
||||
'use client'
|
||||
import type { Metadata } from 'next'
|
||||
import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [statusMessage, setStatusMessage] = useState<{
|
||||
type: 'success' | 'error' | null
|
||||
text: string
|
||||
}>({
|
||||
type: null,
|
||||
text: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: 'Invalid or missing reset token. Please request a new password reset link.',
|
||||
})
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleResetPassword = async (password: string) => {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setStatusMessage({ type: null, text: '' })
|
||||
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
newPassword: password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Failed to reset password')
|
||||
}
|
||||
|
||||
setStatusMessage({
|
||||
type: 'success',
|
||||
text: 'Password reset successful! Redirecting to login...',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/login?resetSuccess=true')
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
logger.error('Error resetting password:', { error })
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Failed to reset password',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reset Password',
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
export default ResetPasswordPage
|
||||
|
||||
117
apps/sim/app/(auth)/reset-password/reset-password-content.tsx
Normal file
117
apps/sim/app/(auth)/reset-password/reset-password-content.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [statusMessage, setStatusMessage] = useState<{
|
||||
type: 'success' | 'error' | null
|
||||
text: string
|
||||
}>({
|
||||
type: null,
|
||||
text: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: 'Invalid or missing reset token. Please request a new password reset link.',
|
||||
})
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleResetPassword = async (password: string) => {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setStatusMessage({ type: null, text: '' })
|
||||
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
newPassword: password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Failed to reset password')
|
||||
}
|
||||
|
||||
setStatusMessage({
|
||||
type: 'success',
|
||||
text: 'Password reset successful! Redirecting to login...',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/login?resetSuccess=true')
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
logger.error('Error resetting password:', { error })
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Failed to reset password',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import SignupForm from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign Up',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SignupPage() {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import SSOForm from '@/ee/sso/components/sso-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Single Sign-On',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SSOPage() {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
|
||||
import { hasEmailService } from '@/lib/messaging/email/mailer'
|
||||
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Verify Email',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function VerifyPage() {
|
||||
|
||||
@@ -43,7 +43,7 @@ function VerificationForm({
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (countdown === 0 && isResendDisabled) {
|
||||
|
||||
332
apps/sim/app/(home)/components/collaboration/collaboration.tsx
Normal file
332
apps/sim/app/(home)/components/collaboration/collaboration.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
apps/sim/app/(home)/components/enterprise/enterprise.tsx
Normal file
17
apps/sim/app/(home)/components/enterprise/enterprise.tsx
Normal 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
|
||||
}
|
||||
229
apps/sim/app/(home)/components/features/features.tsx
Normal file
229
apps/sim/app/(home)/components/features/features.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/components/footer/footer.tsx
Normal file
18
apps/sim/app/(home)/components/footer/footer.tsx
Normal 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
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
132
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
132
apps/sim/app/(home)/components/hero/hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/(home)/components/index.ts
Normal file
23
apps/sim/app/(home)/components/index.ts
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>“$1”</em>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Tool entry displayed as a chip on agent blocks
|
||||
*/
|
||||
export interface PreviewTool {
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Static block definition for preview workflow nodes
|
||||
*/
|
||||
export interface PreviewBlock {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
tools?: PreviewTool[]
|
||||
markdown?: string
|
||||
position: { x: number; y: number }
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow definition containing nodes, edges, and metadata
|
||||
*/
|
||||
export interface PreviewWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
blocks: PreviewBlock[]
|
||||
edges: Array<{ id: string; source: string; target: string }>
|
||||
/** Public JSON export used to seed the landing-page import flow */
|
||||
seedPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* IT Service Management workflow — Slack Trigger -> Agent (KB tool) -> Jira
|
||||
*/
|
||||
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-it-service',
|
||||
name: 'IT Service Management',
|
||||
color: '#FF6B2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#it-support' },
|
||||
{ title: 'Event', value: 'New Message' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Triage incoming IT...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#10B981' }],
|
||||
position: { x: 420, y: 40 },
|
||||
},
|
||||
{
|
||||
id: 'jira-1',
|
||||
name: 'Jira',
|
||||
type: 'jira',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Get Issues' },
|
||||
{ title: 'Project', value: 'IT-Support' },
|
||||
],
|
||||
position: { x: 420, y: 260 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'slack-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'slack-1', target: 'jira-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
|
||||
*/
|
||||
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-content-pipeline',
|
||||
name: 'Content Pipeline',
|
||||
color: '#33C482',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||
],
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty "New Agent" workflow — a single note prompting the user to start building
|
||||
*/
|
||||
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-new-agent',
|
||||
name: 'New Agent',
|
||||
color: '#787878',
|
||||
blocks: [
|
||||
{
|
||||
id: 'note-1',
|
||||
name: '',
|
||||
type: 'note',
|
||||
bgColor: 'transparent',
|
||||
rows: [],
|
||||
markdown: '### What will you build?\n\n_"Find Linear todos and send in Slack"_',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
CONTENT_PIPELINE_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
/** Stagger delay between each block appearing (seconds). */
|
||||
export const BLOCK_STAGGER = 0.12
|
||||
|
||||
/** Shared cubic-bezier easing — fast deceleration, gentle settle. */
|
||||
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||
|
||||
/** Shared edge style applied to all preview workflow connections */
|
||||
const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
|
||||
|
||||
/**
|
||||
* Converts a PreviewWorkflow to React Flow nodes and edges.
|
||||
*
|
||||
* @param workflow - The workflow definition
|
||||
* @param animate - When true, node/edge data includes animation metadata
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false
|
||||
): {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
} {
|
||||
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => ({
|
||||
id: block.id,
|
||||
type: 'previewBlock',
|
||||
position: block.position,
|
||||
data: {
|
||||
name: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
tools: block.tools,
|
||||
markdown: block.markdown,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
hideSourceHandle: block.hideSourceHandle,
|
||||
index,
|
||||
animate,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}))
|
||||
|
||||
const edges: Edge[] = workflow.edges.map((e) => {
|
||||
const sourceIndex = blockIndexMap.get(e.source) ?? 0
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: EDGE_STYLE,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
97
apps/sim/app/(home)/components/navbar/navbar.tsx
Normal file
97
apps/sim/app/(home)/components/navbar/navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
204
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
|
||||
interface PricingTier {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
billingPeriod?: string
|
||||
color: string
|
||||
features: string[]
|
||||
cta: { label: string; href: string }
|
||||
}
|
||||
|
||||
const PRICING_TIERS: PricingTier[] = [
|
||||
{
|
||||
id: 'community',
|
||||
name: 'Community',
|
||||
description: 'For individuals getting started with AI agents',
|
||||
price: 'Free',
|
||||
color: '#2ABBF8',
|
||||
features: [
|
||||
'3,000 credits/mo',
|
||||
'5GB file storage',
|
||||
'5 min execution limit',
|
||||
'Limited log retention',
|
||||
'CLI/SDK Access',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
description: 'For professionals building production workflows',
|
||||
price: '$25',
|
||||
billingPeriod: 'per month',
|
||||
color: '#00F701',
|
||||
features: [
|
||||
'6,000 credits/mo',
|
||||
'+50 daily refresh credits',
|
||||
'150 runs/min (sync)',
|
||||
'50 min sync execution limit',
|
||||
'50GB file storage',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'max',
|
||||
name: 'Max',
|
||||
description: 'For power users and teams building at scale',
|
||||
price: '$100',
|
||||
billingPeriod: 'per month',
|
||||
color: '#FA4EDF',
|
||||
features: [
|
||||
'25,000 credits/mo',
|
||||
'+200 daily refresh credits',
|
||||
'300 runs/min (sync)',
|
||||
'50 min sync execution limit',
|
||||
'500GB file storage',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'For organizations needing security and scale',
|
||||
price: 'Custom',
|
||||
color: '#FFCC02',
|
||||
features: ['Custom infra limits', 'SSO', 'SOC2', 'Self hosting', 'Dedicated support'],
|
||||
cta: { label: 'Book a demo', href: '/contact' },
|
||||
},
|
||||
]
|
||||
|
||||
function CheckIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
|
||||
<path
|
||||
d='M2.5 7L5.5 10L11.5 4'
|
||||
stroke={color}
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface PricingCardProps {
|
||||
tier: PricingTier
|
||||
}
|
||||
|
||||
function PricingCard({ tier }: PricingCardProps) {
|
||||
const isEnterprise = tier.id === 'enterprise'
|
||||
const isPro = tier.id === 'pro'
|
||||
|
||||
return (
|
||||
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
|
||||
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[#E5E5E5] border-b-0 bg-white p-5'>
|
||||
<div className='flex flex-col'>
|
||||
<h3
|
||||
id={`${tier.id}-heading`}
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<p className='mt-2 min-h-[44px] font-[430] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className='mt-4 flex items-center gap-1.5 font-[430] font-season text-[#1C1C1C] text-[20px] leading-[100%] tracking-[-0.02em]'>
|
||||
{tier.price}
|
||||
{tier.billingPeriod && (
|
||||
<span className='text-[#737373] text-[16px]'>{tier.billingPeriod}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
{isEnterprise ? (
|
||||
<a
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</a>
|
||||
) : isPro ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className='flex flex-col gap-2'>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className='flex items-center gap-2'>
|
||||
<CheckIcon color='#404040' />
|
||||
<span className='font-[400] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='relative h-[6px]'>
|
||||
<div
|
||||
className='absolute inset-0 rounded-b-sm opacity-60'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 left-[12%] rounded-b-sm opacity-60'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 left-[25%] rounded-b-sm'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing section -- tiered pricing plans with feature comparison.
|
||||
*/
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#2ABBF8]/10 font-season text-[#2ABBF8] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Pricing
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='pricing-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Pricing
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{PRICING_TIERS.map((tier) => (
|
||||
<PricingCard key={tier.id} tier={tier} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
224
apps/sim/app/(home)/components/structured-data.tsx
Normal file
224
apps/sim/app/(home)/components/structured-data.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* JSON-LD structured data for the landing page.
|
||||
*
|
||||
* Renders a `<script type="application/ld+json">` with Schema.org markup.
|
||||
* Single source of truth for machine-readable page metadata.
|
||||
*
|
||||
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, FAQPage.
|
||||
*
|
||||
* AI crawler behavior (2025-2026):
|
||||
* - Google AI Overviews / Bing Copilot parse JSON-LD from their search indexes.
|
||||
* - GPTBot indexes JSON-LD during crawling (92% of LLM crawlers parse JSON-LD first).
|
||||
* - Perplexity / Claude prioritize visible HTML over JSON-LD during direct fetch.
|
||||
* - All claims here must also appear as visible text on the page.
|
||||
*
|
||||
* Maintenance:
|
||||
* - Offer prices must match the Pricing component exactly.
|
||||
* - `sameAs` links must match the Footer social links.
|
||||
* - Do not add `aggregateRating` without real, verifiable review data.
|
||||
*/
|
||||
export default function StructuredData() {
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
name: 'Sim',
|
||||
alternateName: 'Sim Studio',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
url: 'https://sim.ai',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
'@id': 'https://sim.ai/#logo',
|
||||
url: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||
contentUrl: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||
width: 49.78314,
|
||||
height: 24.276,
|
||||
caption: 'Sim Logo',
|
||||
},
|
||||
image: { '@id': 'https://sim.ai/#logo' },
|
||||
sameAs: [
|
||||
'https://x.com/simdotai',
|
||||
'https://github.com/simstudioai/sim',
|
||||
'https://www.linkedin.com/company/simstudioai/',
|
||||
'https://discord.gg/Hr4UWYEcTT',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer support',
|
||||
availableLanguage: ['en'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://sim.ai/#website',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
|
||||
publisher: { '@id': 'https://sim.ai/#organization' },
|
||||
inLanguage: 'en-US',
|
||||
},
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': 'https://sim.ai/#webpage',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
isPartOf: { '@id': 'https://sim.ai/#website' },
|
||||
about: { '@id': 'https://sim.ai/#software' },
|
||||
datePublished: '2024-01-01T00:00:00+00:00',
|
||||
dateModified: new Date().toISOString(),
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
breadcrumb: { '@id': 'https://sim.ai/#breadcrumb' },
|
||||
inLanguage: 'en-US',
|
||||
potentialAction: [{ '@type': 'ReadAction', target: ['https://sim.ai'] }],
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': 'https://sim.ai/#breadcrumb',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'WebApplication',
|
||||
'@id': 'https://sim.ai/#software',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
browserRequirements: 'Requires a modern browser with JavaScript enabled',
|
||||
offers: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Community Plan — 3,000 credits included',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Pro Plan — 6,000 credits/month',
|
||||
price: '25',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '25',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Max Plan — 25,000 credits/month',
|
||||
price: '100',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '100',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
],
|
||||
featureList: [
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
'Event triggers',
|
||||
],
|
||||
review: [
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'Hasan Toor' },
|
||||
reviewBody:
|
||||
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
|
||||
url: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'nizzy' },
|
||||
reviewBody:
|
||||
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
|
||||
url: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Organization', name: 'xyflow' },
|
||||
reviewBody: 'A very good looking agent workflow builder and open source!',
|
||||
url: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://sim.ai/#faq',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Which AI models does Sim support?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim supports all major AI models including OpenAI (GPT-5, GPT-4o), Anthropic (Claude), Google (Gemini), xAI (Grok), Mistral, Perplexity, and many more. You can also connect to open-source models via Ollama.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How much does Sim cost?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers a free Community plan with 3,000 credits, a Pro plan at $25/month with 6,000 credits, a Max plan at $100/month with 25,000 credits, team plans available for both tiers, and custom Enterprise pricing. All plans include CLI/SDK access.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need coding skills to use Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What enterprise features does Sim offer?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
595
apps/sim/app/(home)/components/templates/template-workflows.ts
Normal file
595
apps/sim/app/(home)/components/templates/template-workflows.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* OCR Invoice to DB — Start → Agent (Textract) → Supabase
|
||||
* Pattern: Straight line (all blocks aligned at top)
|
||||
*/
|
||||
const OCR_INVOICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-ocr-invoice',
|
||||
name: 'OCR Invoice to DB',
|
||||
color: '#2ABBF8',
|
||||
seedPath: '/landing-page-templates/ocr-invoice-db-d502887e-8750-40a3-a98b-fb2a4e725a4d.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'URL', value: 'invoice.pdf' }],
|
||||
position: { x: 40, y: 80 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Extract invoice fields...' },
|
||||
],
|
||||
tools: [{ name: 'Textract', type: 'textract', bgColor: '#055F4E' }],
|
||||
position: { x: 400, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'supabase-1',
|
||||
name: 'Supabase',
|
||||
type: 'supabase',
|
||||
bgColor: '#1C1C1C',
|
||||
rows: [
|
||||
{ title: 'Table', value: 'invoices' },
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
],
|
||||
position: { x: 760, y: 80 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'agent-1', target: 'supabase-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Release Agent — GitHub → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const GITHUB_RELEASE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-github-release',
|
||||
name: 'GitHub Release Agent',
|
||||
color: '#00F701',
|
||||
seedPath: '/landing-page-templates/gh-release-agent-d3bed10e-fc87-4fdb-b458-80d8e43757d3.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-1',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Release' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Summarize changelog...' },
|
||||
],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#releases' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-1', target: 'agent-2' },
|
||||
{ id: 'e-2', source: 'agent-2', target: 'slack-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting Follow-up Agent — Google Calendar → Agent → Gmail
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const MEETING_FOLLOWUP_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-meeting-followup',
|
||||
name: 'Meeting Follow-up Agent',
|
||||
color: '#FFCC02',
|
||||
seedPath:
|
||||
'/landing-page-templates/meeting-followup-agent-a2357a2f-67f7-40c1-8e64-301bcd604239.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gcal-1',
|
||||
name: 'Google Calendar',
|
||||
type: 'google_calendar',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Meeting Ended' },
|
||||
{ title: 'Calendar', value: 'Work' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-3',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Draft follow-up email...' },
|
||||
],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'gmail-1',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Send Email' },
|
||||
{ title: 'To', value: 'attendees' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gcal-1', target: 'agent-3' },
|
||||
{ id: 'e-2', source: 'agent-3', target: 'gmail-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* CV/Resume Scanner — Start → Agent (Reducto) → Google Sheets
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const CV_SCANNER_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-cv-scanner',
|
||||
name: 'CV/Resume Scanner',
|
||||
color: '#FA4EDF',
|
||||
seedPath: '/landing-page-templates/resume-parser-d083c931-8788-4c6e-814c-0788830e164d.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-2',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'File URL', value: 'resume.pdf' }],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-4',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-opus-4.6' },
|
||||
{ title: 'System Prompt', value: 'Parse resume fields...' },
|
||||
],
|
||||
tools: [{ name: 'Reducto', type: 'reducto', bgColor: '#5c0c5c' }],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-1',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Candidates' },
|
||||
{ title: 'Operation', value: 'Append Row' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-2', target: 'agent-4' },
|
||||
{ id: 'e-2', source: 'agent-4', target: 'gsheets-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Email Triage Agent — Gmail → Agent (KB) → fan-out to Slack + Linear
|
||||
* Pattern: Fan-out (input low → agent mid → outputs spread vertically)
|
||||
*/
|
||||
const EMAIL_TRIAGE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-email-triage',
|
||||
name: 'Email Triage Agent',
|
||||
color: '#FF6B2C',
|
||||
seedPath: '/landing-page-templates/email-triage-57e84f83-c583-4e74-b73a-080d25f2074d.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmail-2',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Email' },
|
||||
{ title: 'Label', value: 'Inbox' },
|
||||
],
|
||||
position: { x: 60, y: 130 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-5',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2-mini' },
|
||||
{ title: 'System Prompt', value: 'Classify and route...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'slack-2',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#urgent' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 20 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'linear-1',
|
||||
name: 'Linear',
|
||||
type: 'linear',
|
||||
bgColor: '#5E6AD2',
|
||||
rows: [
|
||||
{ title: 'Project', value: 'Support' },
|
||||
{ title: 'Operation', value: 'Create Issue' },
|
||||
],
|
||||
position: { x: 680, y: 200 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gmail-2', target: 'agent-5' },
|
||||
{ id: 'e-2', source: 'agent-5', target: 'slack-2' },
|
||||
{ id: 'e-3', source: 'agent-5', target: 'linear-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Competitor Monitor — Schedule → Agent (Firecrawl) → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const COMPETITOR_MONITOR_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-competitor-monitor',
|
||||
name: 'Competitor Monitor',
|
||||
color: '#6366F1',
|
||||
seedPath: '/landing-page-templates/competitor-monitor-52454688-49ae-4279-894a-aa6494f10e3a.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '08:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 50 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-6',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Monitor competitor...' },
|
||||
],
|
||||
tools: [{ name: 'Firecrawl', type: 'firecrawl', bgColor: '#181C1E' }],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'slack-3',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#competitive-intel' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 50 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-1', target: 'agent-6' },
|
||||
{ id: 'e-2', source: 'agent-6', target: 'slack-3' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Social Listening Agent — Schedule → Agent (Reddit + X) → Notion
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const SOCIAL_LISTENING_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-social-listening',
|
||||
name: 'Social Listening Agent',
|
||||
color: '#F43F5E',
|
||||
seedPath: '/landing-page-templates/brand-mention-d2578496-d153-4db1-8ef1-c738cfa94a96.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-2',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [{ title: 'Run Frequency', value: 'Hourly' }],
|
||||
position: { x: 60, y: 150 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-7',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-flash' },
|
||||
{ title: 'System Prompt', value: 'Track brand mentions...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Reddit', type: 'reddit', bgColor: '#FF5700' },
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'notion-1',
|
||||
name: 'Notion',
|
||||
type: 'notion',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Database', value: 'Brand Mentions' },
|
||||
{ title: 'Operation', value: 'Create Page' },
|
||||
],
|
||||
position: { x: 680, y: 150 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-2', target: 'agent-7' },
|
||||
{ id: 'e-2', source: 'agent-7', target: 'notion-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Enrichment Pipeline — Start → Agent (LinkedIn) → Google Sheets
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const DATA_ENRICHMENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-data-enrichment',
|
||||
name: 'Data Enrichment Pipeline',
|
||||
color: '#14B8A6',
|
||||
seedPath: '/landing-page-templates/lead-enricher-6ed8dede-1df6-4962-95f4-887abf524b38.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-3',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Email', value: 'lead@company.com' }],
|
||||
position: { x: 60, y: 55 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-8',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'mistral-large' },
|
||||
{ title: 'System Prompt', value: 'Enrich lead data...' },
|
||||
],
|
||||
tools: [{ name: 'LinkedIn', type: 'linkedin', bgColor: '#0072B1' }],
|
||||
position: { x: 370, y: 145 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-2',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Lead Database' },
|
||||
{ title: 'Operation', value: 'Update Row' },
|
||||
],
|
||||
position: { x: 680, y: 55 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-3', target: 'agent-8' },
|
||||
{ id: 'e-2', source: 'agent-8', target: 'gsheets-2' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Feedback Digest — Schedule → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const FEEDBACK_DIGEST_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-feedback-digest',
|
||||
name: 'Customer Feedback Digest',
|
||||
color: '#F59E0B',
|
||||
seedPath:
|
||||
'/landing-page-templates/customer-feedback-digest-2a1c59de-fdcc-4ae0-b448-69a58b36c29a.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-3',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-9',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Analyze customer feedback...' },
|
||||
],
|
||||
tools: [{ name: 'Airtable', type: 'airtable', bgColor: '#18BFFF' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-4',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#product-feedback' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-3', target: 'agent-9' },
|
||||
{ id: 'e-2', source: 'agent-9', target: 'slack-4' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* PR Review Agent — GitHub → Agent → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const PR_REVIEW_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-pr-review',
|
||||
name: 'PR Review Agent',
|
||||
color: '#06B6D4',
|
||||
seedPath: '/landing-page-templates/pr-review-cb5f2d92-a324-4958-8303-4710c572b71d.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-2',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Pull Request Opened' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-10',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Review code changes...' },
|
||||
],
|
||||
position: { x: 370, y: 155 },
|
||||
},
|
||||
{
|
||||
id: 'slack-5',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#code-reviews' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-2', target: 'agent-10' },
|
||||
{ id: 'e-2', source: 'agent-10', target: 'slack-5' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge Base QA — Start → Agent (KB) → Response
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const KNOWLEDGE_QA_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-knowledge-qa',
|
||||
name: 'Knowledge Base QA',
|
||||
color: '#84CC16',
|
||||
seedPath: '/landing-page-templates/knowledge-base-qa-e9dcd9f1-18bd-4163-b5d8-3e239a297526.json',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-4',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Question', value: 'How do I...' }],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-11',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Answer using knowledge...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'starter-5',
|
||||
name: 'Response',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Answer', value: 'Based on your docs...' }],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-4', target: 'agent-11' },
|
||||
{ id: 'e-2', source: 'agent-11', target: 'starter-5' },
|
||||
],
|
||||
}
|
||||
|
||||
export const TEMPLATE_WORKFLOWS: PreviewWorkflow[] = [
|
||||
OCR_INVOICE_WORKFLOW,
|
||||
GITHUB_RELEASE_WORKFLOW,
|
||||
MEETING_FOLLOWUP_WORKFLOW,
|
||||
CV_SCANNER_WORKFLOW,
|
||||
EMAIL_TRIAGE_WORKFLOW,
|
||||
COMPETITOR_MONITOR_WORKFLOW,
|
||||
SOCIAL_LISTENING_WORKFLOW,
|
||||
DATA_ENRICHMENT_WORKFLOW,
|
||||
FEEDBACK_DIGEST_WORKFLOW,
|
||||
PR_REVIEW_WORKFLOW,
|
||||
KNOWLEDGE_QA_WORKFLOW,
|
||||
]
|
||||
596
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
596
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { LandingWorkflowSeedStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
|
||||
|
||||
const logger = createLogger('LandingTemplates')
|
||||
|
||||
const LandingPreviewWorkflow = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
).then((mod) => mod.LandingPreviewWorkflow),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='h-full w-full bg-[#1b1b1b]' />,
|
||||
}
|
||||
)
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16)
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
const LEFT_WALL_CLIP = 'polygon(0 8px, 100% 0, 100% 100%, 0 100%)'
|
||||
const BOTTOM_WALL_CLIP = 'polygon(0 0, 100% 0, calc(100% - 8px) 100%, 0 100%)'
|
||||
|
||||
interface DepthConfig {
|
||||
color: string
|
||||
segments: readonly (readonly [opacity: number, width: number])[]
|
||||
}
|
||||
|
||||
/** Depth color and gradient segment pattern per template. Segments are `[opacity, width%]` tuples. */
|
||||
const DEPTH_CONFIGS: Record<string, DepthConfig> = {
|
||||
'tpl-ocr-invoice': {
|
||||
color: '#2ABBF8',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.5, 8],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 8],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.9, 7],
|
||||
[0.6, 12],
|
||||
[1, 8],
|
||||
[0.35, 8],
|
||||
],
|
||||
},
|
||||
'tpl-github-release': {
|
||||
color: '#00F701',
|
||||
segments: [
|
||||
[0.4, 8],
|
||||
[0.7, 6],
|
||||
[1, 5],
|
||||
[0.5, 14],
|
||||
[0.85, 8],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 7],
|
||||
[0.45, 8],
|
||||
[1, 8],
|
||||
[0.7, 8],
|
||||
],
|
||||
},
|
||||
'tpl-meeting-followup': {
|
||||
color: '#FFCC02',
|
||||
segments: [
|
||||
[0.5, 12],
|
||||
[0.8, 6],
|
||||
[0.35, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 14],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 8],
|
||||
[1, 6],
|
||||
[0.3, 8],
|
||||
],
|
||||
},
|
||||
'tpl-cv-scanner': {
|
||||
color: '#FA4EDF',
|
||||
segments: [
|
||||
[0.35, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 5],
|
||||
[1, 6],
|
||||
[0.4, 8],
|
||||
[0.75, 12],
|
||||
[0.5, 7],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.8, 8],
|
||||
[0.6, 9],
|
||||
[1, 6],
|
||||
[0.45, 8],
|
||||
],
|
||||
},
|
||||
'tpl-email-triage': {
|
||||
color: '#FF6B2C',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.7, 8],
|
||||
[1, 5],
|
||||
[0.5, 12],
|
||||
[0.85, 6],
|
||||
[0.3, 10],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 12],
|
||||
[1, 8],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-competitor-monitor': {
|
||||
color: '#6366F1',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.55, 10],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 7],
|
||||
[0.9, 8],
|
||||
[0.5, 10],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 6],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
],
|
||||
},
|
||||
'tpl-social-listening': {
|
||||
color: '#F43F5E',
|
||||
segments: [
|
||||
[0.5, 10],
|
||||
[0.8, 6],
|
||||
[0.4, 8],
|
||||
[1, 5],
|
||||
[0.6, 12],
|
||||
[0.35, 8],
|
||||
[0.9, 7],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.75, 8],
|
||||
[0.4, 6],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-data-enrichment': {
|
||||
color: '#14B8A6',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.6, 6],
|
||||
[0.9, 5],
|
||||
[0.4, 12],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 7],
|
||||
[0.85, 8],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.65, 8],
|
||||
[1, 7],
|
||||
[0.5, 8],
|
||||
],
|
||||
},
|
||||
'tpl-feedback-digest': {
|
||||
color: '#F59E0B',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.65, 6],
|
||||
[0.9, 5],
|
||||
[0.5, 12],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 7],
|
||||
[1, 5],
|
||||
[0.6, 10],
|
||||
[0.85, 8],
|
||||
[0.45, 6],
|
||||
[1, 8],
|
||||
[0.55, 9],
|
||||
],
|
||||
},
|
||||
'tpl-pr-review': {
|
||||
color: '#06B6D4',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.7, 7],
|
||||
[1, 5],
|
||||
[0.45, 10],
|
||||
[0.8, 6],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 10],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
[0.5, 7],
|
||||
],
|
||||
},
|
||||
'tpl-knowledge-qa': {
|
||||
color: '#84CC16',
|
||||
segments: [
|
||||
[0.5, 8],
|
||||
[0.75, 6],
|
||||
[0.4, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.85, 7],
|
||||
[0.35, 12],
|
||||
[1, 6],
|
||||
[0.7, 8],
|
||||
[0.45, 10],
|
||||
[0.9, 6],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const SCROLL_BLOCK_RX = '2.59574'
|
||||
|
||||
/**
|
||||
* Two-row horizontal block strip for the scroll-driven reveal in the templates section.
|
||||
* Same structural pattern as the hero's top-right blocks with matching colours:
|
||||
* blue (left) → pink (middle) → green (right).
|
||||
*/
|
||||
const SCROLL_BLOCK_RECTS = [
|
||||
{ opacity: 0.6, x: '-34.24', y: '0', width: '34.24', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '-17.38', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.34', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.24', y: '0', width: '34.24', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.24', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.62', y: '16.86', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.48', y: '0', width: '54.65', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '34.24', height: '33.73', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '51.10', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '123.65', y: '16.86', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '157.37', y: '0', width: '34.24', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.37', y: '0', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.0', y: '0', width: '68.48', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '209.14', y: '0', width: '16.86', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '243.23', y: '0', width: '34.24', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 1, x: '243.23', y: '0', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '260.10', y: '0', width: '34.04', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 1, x: '260.61', y: '16.86', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
] as const
|
||||
|
||||
const SCROLL_BLOCK_MAX_X = Math.max(...SCROLL_BLOCK_RECTS.map((r) => Number.parseFloat(r.x)))
|
||||
const SCROLL_REVEAL_START = 0.05
|
||||
const SCROLL_REVEAL_SPAN = 0.7
|
||||
const SCROLL_FADE_IN = 0.03
|
||||
|
||||
function getScrollBlockThreshold(x: string): number {
|
||||
const normalized = Number.parseFloat(x) / SCROLL_BLOCK_MAX_X
|
||||
return SCROLL_REVEAL_START + (1 - normalized) * SCROLL_REVEAL_SPAN
|
||||
}
|
||||
|
||||
interface ScrollBlockRectProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
rect: (typeof SCROLL_BLOCK_RECTS)[number]
|
||||
}
|
||||
|
||||
/** Renders a single SVG rect whose opacity is driven by scroll progress. */
|
||||
function ScrollBlockRect({ scrollYProgress, rect }: ScrollBlockRectProps) {
|
||||
const threshold = getScrollBlockThreshold(rect.x)
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[threshold, threshold + SCROLL_FADE_IN],
|
||||
[0, rect.opacity]
|
||||
)
|
||||
|
||||
return (
|
||||
<motion.rect
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx={SCROLL_BLOCK_RX}
|
||||
fill={rect.fill}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function buildBottomWallStyle(config: DepthConfig) {
|
||||
let pos = 0
|
||||
const stops: string[] = []
|
||||
for (const [opacity, width] of config.segments) {
|
||||
const c = hexToRgba(config.color, opacity)
|
||||
stops.push(`${c} ${pos}%`, `${c} ${pos + width}%`)
|
||||
pos += width
|
||||
}
|
||||
return {
|
||||
clipPath: BOTTOM_WALL_CLIP,
|
||||
background: `linear-gradient(135deg, ${stops.join(', ')})`,
|
||||
}
|
||||
}
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
rows: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TEMPLATES_PANEL_ID = 'templates-panel'
|
||||
|
||||
export default function Templates() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isPreparingTemplate, setIsPreparingTemplate] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
|
||||
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
|
||||
|
||||
const handleUseTemplate = useCallback(async () => {
|
||||
if (isPreparingTemplate) return
|
||||
|
||||
setIsPreparingTemplate(true)
|
||||
|
||||
try {
|
||||
if (activeWorkflow.seedPath) {
|
||||
const response = await fetch(activeWorkflow.seedPath)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch template seed: ${response.status}`)
|
||||
}
|
||||
|
||||
const workflowJson = await response.text()
|
||||
LandingWorkflowSeedStorage.store({
|
||||
templateId: activeWorkflow.id,
|
||||
workflowName: activeWorkflow.name,
|
||||
color: activeWorkflow.color,
|
||||
workflowJson,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to prepare landing template workflow seed', {
|
||||
templateId: activeWorkflow.id,
|
||||
error,
|
||||
})
|
||||
} finally {
|
||||
setIsPreparingTemplate(false)
|
||||
router.push('/signup')
|
||||
}
|
||||
}, [
|
||||
activeWorkflow.color,
|
||||
activeWorkflow.id,
|
||||
activeWorkflow.name,
|
||||
activeWorkflow.seedPath,
|
||||
isPreparingTemplate,
|
||||
router,
|
||||
])
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id='templates'
|
||||
aria-labelledby='templates-heading'
|
||||
className='mt-[40px] mb-[80px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
|
||||
processing, release management, meeting follow-ups, resume scanning, email triage,
|
||||
competitor monitoring, social listening, data enrichment, feedback analysis, code review,
|
||||
and knowledge base Q&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 case—pick one, swap <br />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[73px] flex border-[#2A2A2A] border-y'>
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-r p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='flex min-w-0 flex-1'>
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Workflow templates'
|
||||
className='flex w-[300px] shrink-0 flex-col border-[#2A2A2A] border-r'
|
||||
>
|
||||
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
|
||||
const isActive = index === activeIndex
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
id={`template-tab-${index}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
aria-controls={TEMPLATES_PANEL_ID}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
'relative text-left',
|
||||
isActive
|
||||
? 'z-10'
|
||||
: 'flex items-center px-[12px] py-[10px] shadow-[inset_0_-1px_0_0_#2A2A2A] last:shadow-none hover:bg-[#232323]/50'
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
(() => {
|
||||
const depth = DEPTH_CONFIGS[workflow.id]
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute top-[-8px] bottom-0 left-0 w-2'
|
||||
style={{
|
||||
clipPath: LEFT_WALL_CLIP,
|
||||
backgroundColor: hexToRgba(depth.color, 0.63),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute right-[-8px] bottom-0 left-2 h-2'
|
||||
style={buildBottomWallStyle(depth)}
|
||||
/>
|
||||
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
|
||||
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='-rotate-90 h-[11px] w-[11px] shrink-0'
|
||||
style={{ color: depth.color }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={TEMPLATES_PANEL_ID}
|
||||
role='tabpanel'
|
||||
aria-labelledby={`template-tab-${activeIndex}`}
|
||||
className='relative hidden flex-1 lg:block'
|
||||
>
|
||||
<div aria-hidden='true' className='h-full'>
|
||||
<LandingPreviewWorkflow
|
||||
key={activeIndex}
|
||||
workflow={activeWorkflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/components/testimonials/testimonials.tsx
Normal file
18
apps/sim/app/(home)/components/testimonials/testimonials.tsx
Normal 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
|
||||
}
|
||||
53
apps/sim/app/(home)/landing.tsx
Normal file
53
apps/sim/app/(home)/landing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/layout.tsx
Normal file
18
apps/sim/app/(home)/layout.tsx
Normal 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>
|
||||
}
|
||||
@@ -11,16 +11,14 @@ import {
|
||||
Database,
|
||||
DollarSign,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
Timer,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import {
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
PRO_PLAN_FEATURES,
|
||||
TEAM_PLAN_FEATURES,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
|
||||
import { ENTERPRISE_PLAN_FEATURES } from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
|
||||
|
||||
const logger = createLogger('LandingPricing')
|
||||
|
||||
@@ -38,20 +36,30 @@ interface PricingTier {
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Free plan features with consistent icons
|
||||
*/
|
||||
const FREE_PLAN_FEATURES: PricingFeature[] = [
|
||||
{ icon: DollarSign, text: '$20 usage limit' },
|
||||
{ icon: DollarSign, text: '3,000 credits/mo' },
|
||||
{ icon: HardDrive, text: '5GB file storage' },
|
||||
{ icon: Timer, text: '5 min execution limit' },
|
||||
{ icon: Database, text: 'Limited log retention' },
|
||||
{ icon: Code2, text: 'CLI/SDK Access' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Available pricing tiers with their features and pricing
|
||||
*/
|
||||
const PRO_LANDING_FEATURES: PricingFeature[] = [
|
||||
{ icon: DollarSign, text: '6,000 credits/mo' },
|
||||
{ icon: RefreshCw, text: '+50 daily refresh credits' },
|
||||
{ icon: Zap, text: '150 runs/min (sync)' },
|
||||
{ icon: Timer, text: '50 min sync execution limit' },
|
||||
{ icon: HardDrive, text: '50GB file storage' },
|
||||
]
|
||||
|
||||
const MAX_LANDING_FEATURES: PricingFeature[] = [
|
||||
{ icon: DollarSign, text: '25,000 credits/mo' },
|
||||
{ icon: RefreshCw, text: '+200 daily refresh credits' },
|
||||
{ icon: Zap, text: '300 runs/min (sync)' },
|
||||
{ icon: Timer, text: '50 min sync execution limit' },
|
||||
{ icon: HardDrive, text: '500GB file storage' },
|
||||
]
|
||||
|
||||
const pricingTiers: PricingTier[] = [
|
||||
{
|
||||
name: 'COMMUNITY',
|
||||
@@ -63,16 +71,16 @@ const pricingTiers: PricingTier[] = [
|
||||
{
|
||||
name: 'PRO',
|
||||
tier: 'Pro',
|
||||
price: '$20/mo',
|
||||
features: PRO_PLAN_FEATURES,
|
||||
price: '$25/mo',
|
||||
features: PRO_LANDING_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: 'TEAM',
|
||||
tier: 'Team',
|
||||
price: '$40/mo',
|
||||
features: TEAM_PLAN_FEATURES,
|
||||
name: 'MAX',
|
||||
tier: 'Max',
|
||||
price: '$100/mo',
|
||||
features: MAX_LANDING_FEATURES,
|
||||
ctaText: 'Get Started',
|
||||
},
|
||||
{
|
||||
@@ -84,12 +92,6 @@ const pricingTiers: PricingTier[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Individual pricing card component
|
||||
* @param tier - The pricing tier data
|
||||
* @param index - The index of the card in the grid
|
||||
* @param isBeforeFeatured - Whether this card is immediately before a featured card
|
||||
*/
|
||||
function PricingCard({
|
||||
tier,
|
||||
index,
|
||||
@@ -106,10 +108,8 @@ function PricingCard({
|
||||
logger.info(`Pricing CTA clicked: ${tier.name}`)
|
||||
|
||||
if (tier.ctaText === 'Contact Sales') {
|
||||
// Open enterprise form in new tab
|
||||
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
|
||||
} else {
|
||||
// Navigate to signup page for all "Get Started" buttons
|
||||
router.push('/signup')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,8 +4,8 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
apple: '/apple-icon.png',
|
||||
icon: [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }],
|
||||
apple: '/favicon/apple-touch-icon.png',
|
||||
},
|
||||
other: {
|
||||
'msapplication-TileColor': '#000000',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
@@ -5,6 +6,17 @@ import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
const author = posts[0]?.author
|
||||
return { title: author?.name ?? 'Author' }
|
||||
}
|
||||
|
||||
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { PostGrid } from '@/app/(landing)/studio/post-grid'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Studio',
|
||||
description: 'Announcements, insights, and guides from the Sim team.',
|
||||
}
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export default async function StudioIndex({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllTags } from '@/lib/blog/registry'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tags',
|
||||
}
|
||||
|
||||
export default async function TagsIndex() {
|
||||
const tags = await getAllTags()
|
||||
return (
|
||||
|
||||
118
apps/sim/app/_shell/dynamic-favicon.tsx
Normal file
118
apps/sim/app/_shell/dynamic-favicon.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const DEFAULT_FAVICON = '/icon.svg'
|
||||
|
||||
/**
|
||||
* Sidebar icon color: --text-icon is #5e5e5e (light) / #939393 (dark).
|
||||
* Favicon needs to be visible on any tab bar, so we use a slightly stronger value.
|
||||
*/
|
||||
const ICON_COLOR_LIGHT = '%235e5e5e'
|
||||
const ICON_COLOR_DARK = '%23939393'
|
||||
|
||||
function pad(viewBox: string): string {
|
||||
const [x, y, w, h] = viewBox.split(' ').map(Number)
|
||||
const p = Math.max(w, h) * 0.1
|
||||
return `${x - p} ${y - p} ${w + p * 2} ${h + p * 2}`
|
||||
}
|
||||
|
||||
function wrapStrokeIcon(viewBox: string, paths: string): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${pad(viewBox)}" fill="none"><style>path,circle,ellipse,rect,line{stroke:${ICON_COLOR_LIGHT};stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round;fill:none}@media(prefers-color-scheme:dark){path,circle,ellipse,rect,line{stroke:${ICON_COLOR_DARK}}}</style>${paths}</svg>`
|
||||
return `data:image/svg+xml,${svg}`
|
||||
}
|
||||
|
||||
function wrapFilledIcon(viewBox: string, paths: string): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${pad(viewBox)}" fill="none"><style>.f{fill:${ICON_COLOR_LIGHT};stroke:${ICON_COLOR_LIGHT};stroke-width:0.5;stroke-linejoin:round}@media(prefers-color-scheme:dark){.f{fill:${ICON_COLOR_DARK};stroke:${ICON_COLOR_DARK}}}</style>${paths}</svg>`
|
||||
return `data:image/svg+xml,${svg}`
|
||||
}
|
||||
|
||||
const FAVICONS: Record<string, string> = {
|
||||
home: wrapStrokeIcon(
|
||||
'-1 -2 24 24',
|
||||
`<path d="M0.75 9.5L10.25 1L19.75 9.5V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V9.5Z"/><path d="M7.25 18.75V13C7.25 12.4477 7.69772 12 8.25 12H12.25C12.8023 12 13.25 12.4477 13.25 13V18.75"/>`
|
||||
),
|
||||
blimp: wrapFilledIcon(
|
||||
'1.25 4 18 13',
|
||||
`<path class="f" d="M18.24 9.18C18.16 8.94 18 8.74 17.83 8.56L17.83 8.56C17.67 8.4 17.49 8.25 17.3 8.11V5.48C17.3 5.32 17.24 5.17 17.14 5.06C17.06 4.95 16.93 4.89 16.79 4.89H15.93C15.61 4.89 15.32 5.11 15.19 5.44L14.68 6.77C14.05 6.51 13.23 6.22 12.15 6C11.04 5.77 9.66 5.61 7.9 5.61C5.97 5.61 4.56 6.13 3.61 6.89C3.14 7.28 2.78 7.72 2.54 8.19C2.29 8.66 2.18 9.15 2.18 9.63C2.18 10.1 2.29 10.59 2.52 11.06C2.87 11.76 3.48 12.41 4.34 12.89C4.91 13.2 5.61 13.44 6.43 13.56L6.8 14.78C6.94 15.27 7.33 15.59 7.78 15.59H10.56C11.06 15.59 11.48 15.18 11.58 14.61L11.81 13.29C12.31 13.2 12.75 13.09 13.14 12.99C13.74 12.82 14.24 12.64 14.67 12.48L15.19 13.82C15.32 14.16 15.61 14.38 15.93 14.38H16.79C16.93 14.38 17.06 14.31 17.14 14.2C17.24 14.1 17.29 13.95 17.3 13.79V11.15C17.33 11.12 17.37 11.09 17.42 11.07L17.4 11.07L17.42 11.07C17.65 10.89 17.87 10.69 18.04 10.46C18.12 10.35 18.19 10.22 18.24 10.08C18.29 9.94 18.32 9.79 18.32 9.63C18.32 9.47 18.29 9.32 18.24 9.18ZM15.69 5.71C15.73 5.6 15.83 5.53 15.93 5.53H16.74V7.89C16.41 7.7 16.06 7.53 15.71 7.37C15.55 7.29 15.37 7.2 15.15 7.1L15.69 5.71ZM11.05 14.48C11 14.76 10.79 14.95 10.56 14.95H7.78C7.56 14.95 7.38 14.79 7.31 14.56L6.99 13.52C7.22 13.54 7.47 13.55 7.73 13.55C7.79 13.55 7.84 13.55 7.9 13.55C9.05 13.53 10.05 13.45 10.9 13.33C11.02 13.31 11.14 13.29 11.26 13.27L11.05 14.48ZM16.74 13.74H15.93C15.83 13.74 15.73 13.66 15.69 13.56L15.15 12.16C15.36 12.06 15.55 11.97 15.71 11.9C16.06 11.73 16.41 11.56 16.74 11.37V13.74ZM17.75 9.83C17.7 9.95 17.61 10.08 17.48 10.22C17.4 10.3 17.3 10.38 17.2 10.46C17.07 10.57 16.91 10.67 16.74 10.77C16.71 10.8 16.67 10.82 16.63 10.84C16.29 11.04 15.91 11.23 15.55 11.4C15.38 11.48 15.18 11.57 14.96 11.67C14.82 11.73 14.68 11.79 14.53 11.85C14.12 12.02 13.62 12.2 13.02 12.36C12.65 12.46 12.24 12.56 11.79 12.64C11.65 12.67 11.51 12.7 11.36 12.72C10.4 12.88 9.26 12.99 7.9 13.01C7.84 13.02 7.79 13.02 7.73 13.02C7.41 13.02 7.11 13 6.82 12.97C6.65 12.95 6.48 12.93 6.32 12.9C5.26 12.71 4.45 12.32 3.88 11.84C3.48 11.5 3.19 11.12 2.99 10.74C2.8 10.36 2.72 9.98 2.72 9.63C2.72 9.28 2.81 8.9 3 8.52C3.3 7.95 3.82 7.38 4.63 6.95C5.44 6.53 6.52 6.25 7.9 6.25C10.2 6.25 11.84 6.53 13.05 6.87C13.64 7.04 14.13 7.22 14.53 7.39L14.54 7.4C14.69 7.46 14.83 7.52 14.96 7.59C15.18 7.69 15.37 7.78 15.55 7.86C15.95 8.06 16.38 8.27 16.74 8.49C16.85 8.56 16.96 8.62 17.06 8.69C17.08 8.71 17.1 8.72 17.12 8.74C17.34 8.9 17.51 9.06 17.62 9.22C17.68 9.29 17.72 9.37 17.75 9.44C17.77 9.5 17.78 9.57 17.78 9.63C17.78 9.7 17.77 9.76 17.75 9.83Z"/>`
|
||||
),
|
||||
table: wrapStrokeIcon(
|
||||
'-1 -2 24 24',
|
||||
`<path d="M0.75 3.25C0.75 1.86929 1.86929 0.75 3.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V3.25Z"/><path d="M0.75 6.75H19.75"/><path d="M0.75 12.75H19.75"/><path d="M10.25 0.75V18.75"/>`
|
||||
),
|
||||
files: wrapStrokeIcon(
|
||||
'-1 -2 24 24',
|
||||
`<path d="M12.25 0.75H5.25C4.14543 0.75 3.25 1.64543 3.25 2.75V16.75C3.25 17.8546 4.14543 18.75 5.25 18.75H15.25C16.3546 18.75 17.25 17.8546 17.25 16.75V5.75L12.25 0.75Z"/><path d="M12.25 0.75V5.75H17.25"/>`
|
||||
),
|
||||
knowledge: wrapStrokeIcon(
|
||||
'-1 -2 24 24',
|
||||
`<ellipse cx="10.25" cy="3.75" rx="8.5" ry="3"/><path d="M1.75 3.75V9.75C1.75 11.41 5.55 12.75 10.25 12.75C14.95 12.75 18.75 11.41 18.75 9.75V3.75"/><path d="M1.75 9.75V15.75C1.75 17.41 5.55 18.75 10.25 18.75C14.95 18.75 18.75 17.41 18.75 15.75V9.75"/>`
|
||||
),
|
||||
calendar: wrapStrokeIcon(
|
||||
'-1 -2 24 24',
|
||||
`<path d="M0.75 5.25C0.75 3.86929 1.86929 2.75 3.25 2.75H17.25C18.6307 2.75 19.75 3.86929 19.75 5.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V5.25Z"/><path d="M0.75 8.25H19.75"/><path d="M6.25 0.25V5.25"/><path d="M14.25 0.25V5.25"/>`
|
||||
),
|
||||
library: wrapStrokeIcon(
|
||||
'-1 -2 24 24',
|
||||
`<path d="M0.75 4.75C0.75 3.34987 0.75 2.6498 1.02248 2.11502C1.26217 1.64462 1.64462 1.26217 2.11502 1.02248C2.6498 0.75 3.34987 0.75 4.75 0.75C6.15013 0.75 6.8502 0.75 7.38498 1.02248C7.85538 1.26217 8.23783 1.64462 8.47752 2.11502C8.75 2.6498 8.75 3.34987 8.75 4.75V14.75C8.75 16.1501 8.75 16.8502 8.47752 17.385C8.23783 17.8554 7.85538 18.2378 7.38498 18.4775C6.8502 18.75 6.15013 18.75 4.75 18.75C3.34987 18.75 2.6498 18.75 2.11502 18.4775C1.64462 18.2378 1.26217 17.8554 1.02248 17.385C0.75 16.8502 0.75 16.1501 0.75 14.75V4.75Z"/><path d="M0.75 6.75H8.75"/><path d="M10.1986 6.01843C9.84373 4.68838 9.66628 4.02336 9.78849 3.44599C9.89599 2.93812 10.1608 2.47747 10.5451 2.13005C10.9819 1.73508 11.6442 1.55689 12.9687 1.2005C14.2932 0.844119 14.9555 0.665926 15.5304 0.788649C16.0362 0.8966 16.4949 1.16256 16.8409 1.54841C17.2342 1.98706 17.4117 2.65209 17.7666 3.98213L20.3014 13.4816C20.6563 14.8116 20.8337 15.4766 20.7115 16.054C20.604 16.5619 20.3392 17.0225 19.9549 17.37C19.5181 17.7649 18.8558 17.9431 17.5313 18.2995C16.2068 18.6559 15.5445 18.8341 14.9696 18.7114C14.4638 18.6034 14.0051 18.3374 13.6591 17.9516C13.2658 17.5129 13.0883 16.8479 12.7334 15.5179L10.1986 6.01843Z"/><path d="M10.75 7.75L17.2501 5.75"/>`
|
||||
),
|
||||
settings: wrapStrokeIcon(
|
||||
'0 0 24 24',
|
||||
`<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>`
|
||||
),
|
||||
templates: wrapStrokeIcon(
|
||||
'1 1 22 22',
|
||||
`<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>`
|
||||
),
|
||||
}
|
||||
|
||||
function getRouteSection(pathname: string): string | null {
|
||||
if (!pathname.includes('/workspace/')) return null
|
||||
const parts = pathname.split('/')
|
||||
const sectionIndex = parts.indexOf('workspace') + 2
|
||||
return parts[sectionIndex] ?? null
|
||||
}
|
||||
|
||||
const SECTION_TO_ICON: Record<string, string> = {
|
||||
home: 'home',
|
||||
task: 'home',
|
||||
w: 'blimp',
|
||||
tables: 'table',
|
||||
files: 'files',
|
||||
knowledge: 'knowledge',
|
||||
schedules: 'calendar',
|
||||
logs: 'library',
|
||||
settings: 'settings',
|
||||
templates: 'templates',
|
||||
}
|
||||
|
||||
function setFaviconHrefs(url: string) {
|
||||
document.querySelectorAll<HTMLLinkElement>("link[rel*='icon']").forEach((link) => {
|
||||
if (link.rel === 'apple-touch-icon') return
|
||||
link.href = url
|
||||
})
|
||||
}
|
||||
|
||||
export function DynamicFavicon() {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const section = getRouteSection(pathname)
|
||||
const iconKey = section ? SECTION_TO_ICON[section] : null
|
||||
const url = iconKey ? FAVICONS[iconKey] : DEFAULT_FAVICON
|
||||
|
||||
setFaviconHrefs(url)
|
||||
|
||||
// Re-apply whenever Next.js head reconciliation replaces link elements
|
||||
const observer = new MutationObserver(() => setFaviconHrefs(url))
|
||||
observer.observe(document.head, { childList: true })
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
setFaviconHrefs(DEFAULT_FAVICON)
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -15,26 +15,41 @@ const BROWSER_EXTENSION_ATTRIBUTES = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Client component that intercepts console.error to filter and log hydration errors
|
||||
* while ignoring errors caused by browser extensions.
|
||||
* Checks whether a hydration error is caused by Radix UI's auto-generated IDs
|
||||
* (`aria-controls`, `id`) differing between server and client. This is a known
|
||||
* harmless artifact of React 19's streaming SSR producing different `useId()`
|
||||
* tree paths. The IDs self-correct on first interaction and have no visual or
|
||||
* functional impact since they only link closed (invisible) popover/menu content.
|
||||
*/
|
||||
function isRadixIdMismatch(args: unknown[]): boolean {
|
||||
return args.some(
|
||||
(arg) => typeof arg === 'string' && arg.includes('radix-') && /aria-controls|"\bid\b"/.test(arg)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that intercepts console.error to filter hydration errors
|
||||
* caused by browser extensions or Radix UI's `useId()` mismatch.
|
||||
*/
|
||||
export function HydrationErrorHandler() {
|
||||
useEffect(() => {
|
||||
const originalError = console.error
|
||||
console.error = (...args) => {
|
||||
if (args[0].includes('Hydration')) {
|
||||
if (typeof args[0] === 'string' && args[0].includes('Hydration')) {
|
||||
const isExtensionError = BROWSER_EXTENSION_ATTRIBUTES.some((attr) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes(attr))
|
||||
)
|
||||
|
||||
if (!isExtensionError) {
|
||||
logger.error('Hydration Error', {
|
||||
details: args,
|
||||
componentStack: args.find(
|
||||
(arg) => typeof arg === 'string' && arg.includes('component stack')
|
||||
),
|
||||
})
|
||||
if (isExtensionError || isRadixIdMismatch(args)) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.error('Hydration Error', {
|
||||
details: args,
|
||||
componentStack: args.find(
|
||||
(arg) => typeof arg === 'string' && arg.includes('component stack')
|
||||
),
|
||||
})
|
||||
}
|
||||
originalError.apply(console, args)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import { PostHogProvider as PHProvider } from 'posthog-js/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('PostHogProvider')
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [Provider, setProvider] = useState<React.ComponentType<{
|
||||
client: PostHog
|
||||
children: React.ReactNode
|
||||
}> | null>(null)
|
||||
const clientRef = useRef<PostHog | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
|
||||
const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY')
|
||||
|
||||
if (isTruthy(posthogEnabled) && posthogKey && !posthog.__loaded) {
|
||||
posthog.init(posthogKey, {
|
||||
api_host: '/ingest',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
defaults: '2025-05-24',
|
||||
person_profiles: 'identified_only',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
capture_performance: false,
|
||||
capture_dead_clicks: false,
|
||||
enable_heatmaps: false,
|
||||
session_recording: {
|
||||
maskAllInputs: false,
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: false,
|
||||
},
|
||||
recordCrossOriginIframes: false,
|
||||
recordHeaders: false,
|
||||
recordBody: false,
|
||||
},
|
||||
persistence: 'localStorage+cookie',
|
||||
if (!isTruthy(posthogEnabled) || !posthogKey) return
|
||||
|
||||
Promise.all([import('posthog-js'), import('posthog-js/react')])
|
||||
.then(([posthogModule, { PostHogProvider: PHProvider }]) => {
|
||||
const posthog = posthogModule.default
|
||||
if (!posthog.__loaded) {
|
||||
posthog.init(posthogKey, {
|
||||
api_host: '/ingest',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
defaults: '2025-05-24',
|
||||
person_profiles: 'identified_only',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
capture_performance: false,
|
||||
capture_dead_clicks: false,
|
||||
enable_heatmaps: false,
|
||||
session_recording: {
|
||||
maskAllInputs: false,
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: false,
|
||||
},
|
||||
recordCrossOriginIframes: false,
|
||||
recordHeaders: false,
|
||||
recordBody: false,
|
||||
},
|
||||
persistence: 'localStorage+cookie',
|
||||
})
|
||||
}
|
||||
clientRef.current = posthog
|
||||
setProvider(() => PHProvider)
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load PostHog', { error: err })
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <PHProvider client={posthog}>{children}</PHProvider>
|
||||
if (Provider && clientRef.current) {
|
||||
return <Provider client={clientRef.current}>{children}</Provider>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type React from 'react'
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import posthog from 'posthog-js'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
|
||||
|
||||
@@ -77,22 +76,26 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [loadSession, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || typeof posthog.identify !== 'function') {
|
||||
return
|
||||
}
|
||||
if (isPending) return
|
||||
|
||||
try {
|
||||
if (data?.user) {
|
||||
posthog.identify(data.user.id, {
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
email_verified: data.user.emailVerified,
|
||||
created_at: data.user.createdAt,
|
||||
})
|
||||
} else {
|
||||
posthog.reset()
|
||||
}
|
||||
} catch {}
|
||||
import('posthog-js')
|
||||
.then(({ default: posthog }) => {
|
||||
try {
|
||||
if (typeof posthog.identify !== 'function') return
|
||||
|
||||
if (data?.user) {
|
||||
posthog.identify(data.user.id, {
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
email_verified: data.user.emailVerified,
|
||||
created_at: data.user.createdAt,
|
||||
})
|
||||
} else {
|
||||
posthog.reset()
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [data, isPending])
|
||||
|
||||
const value = useMemo<SessionHookResult>(
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='dark'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
|
||||
14
apps/sim/app/_styles/fonts/martian-mono/martian-mono.ts
Normal file
14
apps/sim/app/_styles/fonts/martian-mono/martian-mono.ts
Normal 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'],
|
||||
})
|
||||
@@ -10,7 +10,7 @@
|
||||
* @see stores/constants.ts for the source of truth
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 232px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
@@ -86,24 +86,29 @@
|
||||
:root,
|
||||
.light {
|
||||
--bg: #fefefe; /* main canvas - neutral near-white */
|
||||
--surface-1: #fefefe; /* sidebar, panels */
|
||||
--surface-1: #f9f9f9; /* sidebar, panels */
|
||||
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
|
||||
--surface-3: #f7f7f7; /* popovers, headers */
|
||||
--surface-4: #f5f5f5; /* buttons base */
|
||||
--border: #e0e0e0; /* primary border */
|
||||
--border: #dedede; /* primary border */
|
||||
--surface-5: #f3f3f3; /* inputs, form elements */
|
||||
--border-1: #e0e0e0; /* stronger border */
|
||||
--surface-6: #f0f0f0; /* popovers, elevated surfaces */
|
||||
--surface-7: #ececec;
|
||||
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
|
||||
--surface-7: #d9d9d9;
|
||||
--surface-active: #e8e8e8; /* hover/active state */
|
||||
|
||||
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
|
||||
|
||||
/* Text - neutral */
|
||||
--text-primary: #2d2d2d;
|
||||
--text-secondary: #404040;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #525252;
|
||||
--text-tertiary: #5c5c5c;
|
||||
--text-muted: #737373;
|
||||
--text-muted: #707070;
|
||||
--text-subtle: #8c8c8c;
|
||||
--text-body: #3b3b3b;
|
||||
--text-icon: #5e5e5e;
|
||||
|
||||
--sidebar-font-weight: 420;
|
||||
--text-inverse: #ffffff;
|
||||
--text-muted-inverse: #a0a0a0;
|
||||
--text-error: #ef4444;
|
||||
@@ -118,6 +123,7 @@
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--selection: #1a5cf6;
|
||||
--warning: #ea580c;
|
||||
|
||||
/* Utility */
|
||||
@@ -125,7 +131,7 @@
|
||||
|
||||
/* Font weights - lighter for light mode */
|
||||
--font-weight-base: 430;
|
||||
--font-weight-medium: 450;
|
||||
--font-weight-medium: 440;
|
||||
--font-weight-semibold: 500;
|
||||
|
||||
/* Extended palette */
|
||||
@@ -207,11 +213,12 @@
|
||||
--surface-2: #232323;
|
||||
--surface-3: #242424;
|
||||
--surface-4: #292929;
|
||||
--border: #2c2c2c;
|
||||
--border: #333333;
|
||||
--surface-5: #363636;
|
||||
--border-1: #3d3d3d;
|
||||
--surface-6: #454545;
|
||||
--surface-7: #454545;
|
||||
--surface-7: #505050;
|
||||
--surface-active: #303030; /* hover/active state */
|
||||
|
||||
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
|
||||
|
||||
@@ -221,6 +228,10 @@
|
||||
--text-tertiary: #b3b3b3;
|
||||
--text-muted: #787878;
|
||||
--text-subtle: #7d7d7d;
|
||||
--text-body: #cdcdcd;
|
||||
--text-icon: #939393;
|
||||
|
||||
--sidebar-font-weight: 420;
|
||||
--text-inverse: #1b1b1b;
|
||||
--text-muted-inverse: #b3b3b3;
|
||||
--text-error: #ef4444;
|
||||
@@ -235,13 +246,14 @@
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--selection: #4b83f7;
|
||||
--warning: #ff6600;
|
||||
|
||||
/* Utility */
|
||||
--white: #ffffff;
|
||||
|
||||
/* Font weights - standard weights for dark mode */
|
||||
--font-weight-base: 440;
|
||||
--font-weight-base: 450;
|
||||
--font-weight-medium: 480;
|
||||
--font-weight-semibold: 550;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
|
||||
|
||||
/**
|
||||
* Gets the effective billing blocked status for a user.
|
||||
@@ -43,27 +45,42 @@ async function getEffectiveBillingStatus(userId: string): Promise<{
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
for (const m of memberships) {
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0 && owners[0].userId !== userId) {
|
||||
const ownerStats = await db
|
||||
.select({
|
||||
blocked: userStats.billingBlocked,
|
||||
blockedReason: userStats.billingBlockedReason,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
// Fetch all org owners in parallel
|
||||
const ownerResults = await Promise.all(
|
||||
memberships.map((m) =>
|
||||
db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
)
|
||||
)
|
||||
|
||||
if (ownerStats.length > 0 && ownerStats[0].blocked) {
|
||||
// Collect owner IDs that are not the current user
|
||||
const otherOwnerIds = ownerResults
|
||||
.filter((owners) => owners.length > 0 && owners[0].userId !== userId)
|
||||
.map((owners) => owners[0].userId)
|
||||
|
||||
if (otherOwnerIds.length > 0) {
|
||||
// Fetch all owner stats in parallel
|
||||
const ownerStatsResults = await Promise.all(
|
||||
otherOwnerIds.map((ownerId) =>
|
||||
db
|
||||
.select({
|
||||
blocked: userStats.billingBlocked,
|
||||
blockedReason: userStats.billingBlockedReason,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, ownerId))
|
||||
.limit(1)
|
||||
)
|
||||
)
|
||||
|
||||
for (const stats of ownerStatsResults) {
|
||||
if (stats.length > 0 && stats[0].blocked) {
|
||||
return {
|
||||
billingBlocked: true,
|
||||
billingBlockedReason: ownerStats[0].blockedReason,
|
||||
billingBlockedReason: stats[0].blockedReason,
|
||||
blockedByOrgOwner: true,
|
||||
}
|
||||
}
|
||||
@@ -114,11 +131,12 @@ export async function GET(request: NextRequest) {
|
||||
let billingData
|
||||
|
||||
if (context === 'user') {
|
||||
// Get user billing (may include organization if they're part of one)
|
||||
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
|
||||
|
||||
// Attach effective billing blocked status (includes org owner check)
|
||||
const billingStatus = await getEffectiveBillingStatus(session.user.id)
|
||||
// Get user billing and billing blocked status in parallel
|
||||
const [billingResult, billingStatus] = await Promise.all([
|
||||
getSimplifiedBillingSummary(session.user.id, contextId || undefined),
|
||||
getEffectiveBillingStatus(session.user.id),
|
||||
])
|
||||
billingData = billingResult
|
||||
|
||||
billingData = {
|
||||
...billingData,
|
||||
@@ -188,10 +206,17 @@ export async function GET(request: NextRequest) {
|
||||
averageUsagePerMember: rawBillingData.averageUsagePerMember,
|
||||
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
|
||||
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
|
||||
members: rawBillingData.members.map((member) => ({
|
||||
...member,
|
||||
joinedAt: member.joinedAt.toISOString(),
|
||||
lastActive: member.lastActive?.toISOString() || null,
|
||||
tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan),
|
||||
totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage),
|
||||
totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit),
|
||||
minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount),
|
||||
averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember),
|
||||
members: rawBillingData.members.map((m) => ({
|
||||
...m,
|
||||
joinedAt: m.joinedAt.toISOString(),
|
||||
lastActive: m.lastActive?.toISOString() || null,
|
||||
currentUsageCredits: dollarsToCredits(m.currentUsage),
|
||||
usageLimitCredits: dollarsToCredits(m.usageLimit),
|
||||
})),
|
||||
}
|
||||
|
||||
|
||||
174
apps/sim/app/api/billing/switch-plan/route.ts
Normal file
174
apps/sim/app/api/billing/switch-plan/route.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { db } from '@sim/db'
|
||||
import { subscription as subscriptionTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
|
||||
import { getPlanByName } from '@/lib/billing/plans'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
const logger = createLogger('SwitchPlan')
|
||||
|
||||
const switchPlanSchema = z.object({
|
||||
targetPlanName: z.string(),
|
||||
interval: z.enum(['month', 'year']).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/billing/switch-plan
|
||||
*
|
||||
* Switches a subscription's tier and/or billing interval via direct Stripe API.
|
||||
* Covers: Pro <-> Max, monthly <-> annual, and team tier changes.
|
||||
* Uses proration -- no Billing Portal redirect.
|
||||
*
|
||||
* Body:
|
||||
* targetPlanName: string -- e.g. 'pro_6000', 'team_25000'
|
||||
* interval?: 'month' | 'year' -- if omitted, keeps the current interval
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = switchPlanSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { targetPlanName, interval } = parsed.data
|
||||
const userId = session.user.id
|
||||
|
||||
const sub = await getHighestPrioritySubscription(userId)
|
||||
if (!sub || !sub.stripeSubscriptionId) {
|
||||
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enterprise plan changes must be handled via support' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const targetPlan = getPlanByName(targetPlanName)
|
||||
if (!targetPlan) {
|
||||
return NextResponse.json({ error: 'Target plan not found' }, { status: 400 })
|
||||
}
|
||||
|
||||
const currentPlanType = getPlanType(sub.plan)
|
||||
const targetPlanType = getPlanType(targetPlanName)
|
||||
if (currentPlanType !== targetPlanType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot switch between individual and team plans via this endpoint' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (isOrgPlan(sub.plan)) {
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const stripe = requireStripeClient()
|
||||
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
|
||||
|
||||
if (stripeSubscription.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
const subscriptionItem = stripeSubscription.items.data[0]
|
||||
if (!subscriptionItem) {
|
||||
return NextResponse.json({ error: 'No subscription item found in Stripe' }, { status: 500 })
|
||||
}
|
||||
|
||||
const currentInterval = subscriptionItem.price?.recurring?.interval
|
||||
const targetInterval = interval ?? currentInterval ?? 'month'
|
||||
|
||||
const targetPriceId =
|
||||
targetInterval === 'year' ? targetPlan.annualDiscountPriceId : targetPlan.priceId
|
||||
|
||||
if (!targetPriceId) {
|
||||
return NextResponse.json(
|
||||
{ error: `No ${targetInterval} price configured for plan ${targetPlanName}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const alreadyOnStripePrice = subscriptionItem.price?.id === targetPriceId
|
||||
const alreadyInDb = sub.plan === targetPlanName
|
||||
|
||||
if (alreadyOnStripePrice && alreadyInDb) {
|
||||
return NextResponse.json({ success: true, message: 'Already on this plan and interval' })
|
||||
}
|
||||
|
||||
logger.info('Switching subscription', {
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
stripeSubscriptionId: sub.stripeSubscriptionId,
|
||||
fromPlan: sub.plan,
|
||||
toPlan: targetPlanName,
|
||||
fromInterval: currentInterval,
|
||||
toInterval: targetInterval,
|
||||
targetPriceId,
|
||||
})
|
||||
|
||||
if (!alreadyOnStripePrice) {
|
||||
const currentQuantity = subscriptionItem.quantity ?? 1
|
||||
|
||||
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: subscriptionItem.id,
|
||||
price: targetPriceId,
|
||||
quantity: currentQuantity,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
})
|
||||
}
|
||||
|
||||
if (!alreadyInDb) {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ plan: targetPlanName })
|
||||
.where(eq(subscriptionTable.id, sub.id))
|
||||
}
|
||||
|
||||
logger.info('Subscription switched successfully', {
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
fromPlan: sub.plan,
|
||||
toPlan: targetPlanName,
|
||||
interval: targetInterval,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch subscription', {
|
||||
userId: session?.user?.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to switch plan' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -106,23 +106,16 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if identifier is available
|
||||
const existingIdentifier = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
// Check identifier availability and workflow access in parallel
|
||||
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
|
||||
db.select().from(chat).where(eq(chat.identifier, identifier)).limit(1),
|
||||
checkWorkflowAccessForChatCreation(workflowId, session.user.id),
|
||||
])
|
||||
|
||||
if (existingIdentifier.length > 0) {
|
||||
return createErrorResponse('Identifier already in use', 400)
|
||||
}
|
||||
|
||||
// Check if user has permission to create chat for this workflow
|
||||
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
|
||||
workflowId,
|
||||
session.user.id
|
||||
)
|
||||
|
||||
if (!hasAccess || !workflowRecord) {
|
||||
return createErrorResponse('Workflow not found or access denied', 404)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
49
apps/sim/app/api/copilot/chat/rename/route.ts
Normal file
49
apps/sim/app/api/copilot/chat/rename/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('RenameChatAPI')
|
||||
|
||||
const RenameChatSchema = z.object({
|
||||
chatId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
})
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { chatId, title } = RenameChatSchema.parse(body)
|
||||
|
||||
const [updated] = await db
|
||||
.update(copilotChats)
|
||||
.set({ title, updatedAt: new Date() })
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
|
||||
.returning({ id: copilotChats.id })
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info('Chat renamed', { chatId, title })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
logger.error('Error renaming chat:', error)
|
||||
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,15 @@ import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { buildConversationHistory } from '@/lib/copilot/chat-context'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import {
|
||||
createSSEStream,
|
||||
requestChatTitle,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import {
|
||||
createStreamEventWriter,
|
||||
resetStreamBuffer,
|
||||
setStreamMeta,
|
||||
} from '@/lib/copilot/orchestrator/stream-buffer'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -23,54 +21,11 @@ import {
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
async function requestChatTitleFromCopilot(params: {
|
||||
message: string
|
||||
model: string
|
||||
provider?: string
|
||||
}): Promise<string | null> {
|
||||
const { message, model, provider } = params
|
||||
if (!message || !model) return null
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/api/generate-chat-title`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
model,
|
||||
...(provider ? { provider } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to generate chat title via copilot backend', {
|
||||
status: response.status,
|
||||
error: payload,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||
return title || null
|
||||
} catch (error) {
|
||||
logger.error('Error generating chat title:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
@@ -81,9 +36,10 @@ const FileAttachmentSchema = z.object({
|
||||
|
||||
const ChatMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
userMessageId: z.string().optional(), // ID from frontend for the user message
|
||||
userMessageId: z.string().optional(),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
model: z.string().optional().default('claude-opus-4-5'),
|
||||
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
|
||||
@@ -93,7 +49,6 @@ const ChatMessageSchema = z.object({
|
||||
implicitFeedback: z.string().optional(),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
provider: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
contexts: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -116,11 +71,11 @@ const ChatMessageSchema = z.object({
|
||||
blockIds: z.array(z.string()).optional(),
|
||||
templateId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
// For workflow_block, provide both workflowId and blockId
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
commands: z.array(z.string()).optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -155,9 +110,9 @@ export async function POST(req: NextRequest) {
|
||||
implicitFeedback,
|
||||
fileAttachments,
|
||||
provider,
|
||||
conversationId,
|
||||
contexts,
|
||||
commands,
|
||||
userTimezone,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
|
||||
const normalizedContexts = Array.isArray(contexts)
|
||||
@@ -174,7 +129,7 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
: contexts
|
||||
|
||||
// Resolve workflowId - if not provided, use first workflow or find by name
|
||||
// Copilot route always requires a workflow scope
|
||||
const resolved = await resolveWorkflowIdForUser(
|
||||
authenticatedUserId,
|
||||
providedWorkflowId,
|
||||
@@ -186,11 +141,22 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
const workflowId = resolved.workflowId
|
||||
const workflowResolvedName = resolved.workflowName
|
||||
|
||||
// Resolve workspace from workflow so it can be sent as implicit context to the Go backend.
|
||||
let resolvedWorkspaceId: string | undefined
|
||||
try {
|
||||
const { getWorkflowById } = await import('@/lib/workflows/utils')
|
||||
const wf = await getWorkflowById(workflowId)
|
||||
resolvedWorkspaceId = wf?.workspaceId ?? undefined
|
||||
} catch {
|
||||
logger.warn(`[${tracker.requestId}] Failed to resolve workspaceId from workflow`)
|
||||
}
|
||||
|
||||
// Ensure we have a consistent user message ID for this request
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] Received chat POST`, {
|
||||
workflowId,
|
||||
hasContexts: Array.isArray(normalizedContexts),
|
||||
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
|
||||
contextsPreview: Array.isArray(normalizedContexts)
|
||||
@@ -204,7 +170,7 @@ export async function POST(req: NextRequest) {
|
||||
: undefined,
|
||||
})
|
||||
} catch {}
|
||||
// Preprocess contexts server-side
|
||||
|
||||
let agentContexts: Array<{ type: string; content: string }> = []
|
||||
if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) {
|
||||
try {
|
||||
@@ -234,7 +200,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat context
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
@@ -249,27 +214,30 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
currentChat = chatResult.chat
|
||||
actualChatId = chatResult.chatId || chatId
|
||||
const history = buildConversationHistory(
|
||||
chatResult.conversationHistory,
|
||||
(chatResult.chat?.conversationId as string | undefined) || conversationId
|
||||
)
|
||||
conversationHistory = history.history
|
||||
conversationHistory = Array.isArray(chatResult.conversationHistory)
|
||||
? chatResult.conversationHistory
|
||||
: []
|
||||
}
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
const effectiveConversationId =
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
|
||||
const userPermission = resolvedWorkspaceId
|
||||
? await getUserEntityPermissions(authenticatedUserId, 'workspace', resolvedWorkspaceId).catch(
|
||||
() => null
|
||||
)
|
||||
: null
|
||||
|
||||
const requestPayload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message,
|
||||
workflowId,
|
||||
workflowId: workflowId || '',
|
||||
workflowName: workflowResolvedName,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
userId: authenticatedUserId,
|
||||
userMessageId: userMessageIdToUse,
|
||||
mode,
|
||||
model: selectedModel,
|
||||
provider,
|
||||
conversationId: effectiveConversationId,
|
||||
conversationHistory,
|
||||
contexts: agentContexts,
|
||||
fileAttachments,
|
||||
@@ -277,6 +245,8 @@ export async function POST(req: NextRequest) {
|
||||
chatId: actualChatId,
|
||||
prefetch,
|
||||
implicitFeedback,
|
||||
userPermission: userPermission ?? undefined,
|
||||
userTimezone,
|
||||
},
|
||||
{
|
||||
selectedModel,
|
||||
@@ -287,7 +257,6 @@ export async function POST(req: NextRequest) {
|
||||
logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
|
||||
hasContext: agentContexts.length > 0,
|
||||
contextCount: agentContexts.length,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
|
||||
messageLength: message.length,
|
||||
mode: effectiveMode,
|
||||
@@ -302,138 +271,35 @@ export async function POST(req: NextRequest) {
|
||||
} catch {}
|
||||
|
||||
if (stream) {
|
||||
const streamId = userMessageIdToUse
|
||||
let eventWriter: ReturnType<typeof createStreamEventWriter> | null = null
|
||||
let clientDisconnected = false
|
||||
const transformedStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
await resetStreamBuffer(streamId)
|
||||
await setStreamMeta(streamId, { status: 'active', userId: authenticatedUserId })
|
||||
eventWriter = createStreamEventWriter(streamId)
|
||||
|
||||
const shouldFlushEvent = (event: Record<string, any>) =>
|
||||
event.type === 'tool_call' ||
|
||||
event.type === 'tool_result' ||
|
||||
event.type === 'tool_error' ||
|
||||
event.type === 'subagent_end' ||
|
||||
event.type === 'structured_result' ||
|
||||
event.type === 'subagent_result' ||
|
||||
event.type === 'done' ||
|
||||
event.type === 'error'
|
||||
|
||||
const pushEvent = async (event: Record<string, any>) => {
|
||||
if (!eventWriter) return
|
||||
const entry = await eventWriter.write(event)
|
||||
if (shouldFlushEvent(event)) {
|
||||
await eventWriter.flush()
|
||||
}
|
||||
const payload = {
|
||||
...event,
|
||||
eventId: entry.eventId,
|
||||
streamId,
|
||||
}
|
||||
try {
|
||||
if (!clientDisconnected) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
|
||||
}
|
||||
} catch {
|
||||
clientDisconnected = true
|
||||
await eventWriter.flush()
|
||||
}
|
||||
}
|
||||
|
||||
if (actualChatId) {
|
||||
await pushEvent({ type: 'chat_id', chatId: actualChatId })
|
||||
}
|
||||
|
||||
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
|
||||
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
await pushEvent({ type: 'title_updated', title })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
onEvent: async (event) => {
|
||||
await pushEvent(event)
|
||||
},
|
||||
})
|
||||
|
||||
if (currentChat && result.conversationId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: result.conversationId,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
await eventWriter.close()
|
||||
await setStreamMeta(streamId, { status: 'complete', userId: authenticatedUserId })
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Orchestration error:`, error)
|
||||
await eventWriter.close()
|
||||
await setStreamMeta(streamId, {
|
||||
status: 'error',
|
||||
userId: authenticatedUserId,
|
||||
error: error instanceof Error ? error.message : 'Stream error',
|
||||
})
|
||||
await pushEvent({
|
||||
type: 'error',
|
||||
data: {
|
||||
displayMessage: 'An unexpected error occurred while processing the response.',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// controller may already be closed by cancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
clientDisconnected = true
|
||||
if (eventWriter) {
|
||||
await eventWriter.close().catch(() => {})
|
||||
}
|
||||
const sseStream = createSSEStream({
|
||||
requestPayload,
|
||||
userId: authenticatedUserId,
|
||||
streamId: userMessageIdToUse,
|
||||
chatId: actualChatId,
|
||||
currentChat,
|
||||
conversationHistory,
|
||||
message,
|
||||
titleModel: selectedModel,
|
||||
titleProvider: provider,
|
||||
requestId: tracker.requestId,
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(transformedStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
|
||||
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
})
|
||||
@@ -485,7 +351,7 @@ export async function POST(req: NextRequest) {
|
||||
// Start title generation in parallel if this is first message (non-streaming)
|
||||
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
|
||||
logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
|
||||
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
|
||||
requestChatTitle({ message, model: selectedModel, provider })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
@@ -509,9 +375,6 @@ export async function POST(req: NextRequest) {
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
...(nonStreamingResult.conversationId
|
||||
? { conversationId: nonStreamingResult.conversationId }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
@@ -563,16 +426,15 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const chatId = searchParams.get('chatId')
|
||||
|
||||
// Get authenticated user using consolidated helper
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !authenticatedUserId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// If chatId is provided, fetch a single chat
|
||||
if (chatId) {
|
||||
const [chat] = await db
|
||||
.select({
|
||||
@@ -582,6 +444,7 @@ export async function GET(req: NextRequest) {
|
||||
messages: copilotChats.messages,
|
||||
planArtifact: copilotChats.planArtifact,
|
||||
config: copilotChats.config,
|
||||
conversationId: copilotChats.conversationId,
|
||||
createdAt: copilotChats.createdAt,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
@@ -601,6 +464,7 @@ export async function GET(req: NextRequest) {
|
||||
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
|
||||
planArtifact: chat.planArtifact || null,
|
||||
config: chat.config || null,
|
||||
conversationId: chat.conversationId || null,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
}
|
||||
@@ -609,11 +473,14 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ success: true, chat: transformedChat })
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
return createBadRequestResponse('workflowId or chatId is required')
|
||||
if (!workflowId && !workspaceId) {
|
||||
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
|
||||
}
|
||||
|
||||
// Fetch chats for this user and workflow
|
||||
const scopeFilter = workflowId
|
||||
? eq(copilotChats.workflowId, workflowId)
|
||||
: eq(copilotChats.workspaceId, workspaceId!)
|
||||
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
@@ -626,12 +493,9 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(eq(copilotChats.userId, authenticatedUserId), eq(copilotChats.workflowId, workflowId))
|
||||
)
|
||||
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
|
||||
.orderBy(desc(copilotChats.updatedAt))
|
||||
|
||||
// Transform the data to include message count
|
||||
const transformedChats = chats.map((chat) => ({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
@@ -644,7 +508,8 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: chat.updatedAt,
|
||||
}))
|
||||
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for workflow ${workflowId}`)
|
||||
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for ${scope}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -123,22 +123,24 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const orgExists = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
// Check org existence and name uniqueness in parallel
|
||||
const [orgExists, existingSet] = await Promise.all([
|
||||
db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: credentialSet.id })
|
||||
.from(credentialSet)
|
||||
.where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name)))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
if (orgExists.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const existingSet = await db
|
||||
.select({ id: credentialSet.id })
|
||||
.from(credentialSet)
|
||||
.where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSet.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A credential set with this name already exists' },
|
||||
|
||||
@@ -251,9 +251,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image-only contexts (copilot, chat, profile-pictures)
|
||||
// Handle copilot, chat, profile-pictures contexts
|
||||
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
|
||||
if (!isImageFileType(file.type)) {
|
||||
if (context === 'copilot') {
|
||||
const { isSupportedFileType: isCopilotSupported } = await import(
|
||||
'@/lib/uploads/contexts/copilot/copilot-file-manager'
|
||||
)
|
||||
if (!isImageFileType(file.type) && !isCopilotSupported(file.type)) {
|
||||
throw new InvalidRequestError(
|
||||
'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
|
||||
)
|
||||
}
|
||||
} else if (!isImageFileType(file.type)) {
|
||||
throw new InvalidRequestError(
|
||||
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ const DuplicateRequestSchema = z.object({
|
||||
workspaceId: z.string().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
color: z.string().optional(),
|
||||
newId: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
|
||||
@@ -33,7 +34,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body)
|
||||
const {
|
||||
name,
|
||||
workspaceId,
|
||||
parentId,
|
||||
color,
|
||||
newId: clientNewId,
|
||||
} = DuplicateRequestSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
|
||||
|
||||
@@ -60,7 +67,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
||||
|
||||
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
|
||||
const newFolderId = crypto.randomUUID()
|
||||
const newFolderId = clientNewId || crypto.randomUUID()
|
||||
const now = new Date()
|
||||
const targetParentId = parentId ?? sourceFolder.parentId
|
||||
|
||||
|
||||
@@ -455,7 +455,7 @@ describe('Folders API Route', () => {
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Name and workspace ID are required')
|
||||
expect(data).toHaveProperty('error', 'Invalid request data')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,12 +3,22 @@ import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, isNull, min } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersAPI')
|
||||
|
||||
const CreateFolderSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
parentId: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
|
||||
// GET - Fetch folders for a workspace
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -59,13 +69,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
workspaceId,
|
||||
parentId,
|
||||
color,
|
||||
sortOrder: providedSortOrder,
|
||||
} = CreateFolderSchema.parse(body)
|
||||
|
||||
if (!name || !workspaceId) {
|
||||
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user has workspace permissions (at least 'write' access to create folders)
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
@@ -79,8 +91,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Generate a new ID
|
||||
const id = crypto.randomUUID()
|
||||
const id = clientId || crypto.randomUUID()
|
||||
|
||||
const newFolder = await db.transaction(async (tx) => {
|
||||
let sortOrder: number
|
||||
@@ -150,6 +161,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ folder: newFolder })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Invalid folder creation data', { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Error creating folder:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -118,21 +118,16 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const existingIdentifier = await db
|
||||
.select()
|
||||
.from(form)
|
||||
.where(eq(form.identifier, identifier))
|
||||
.limit(1)
|
||||
// Check identifier availability and workflow access in parallel
|
||||
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
|
||||
db.select().from(form).where(eq(form.identifier, identifier)).limit(1),
|
||||
checkWorkflowAccessForFormCreation(workflowId, session.user.id),
|
||||
])
|
||||
|
||||
if (existingIdentifier.length > 0) {
|
||||
return createErrorResponse('Identifier already in use', 400)
|
||||
}
|
||||
|
||||
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForFormCreation(
|
||||
workflowId,
|
||||
session.user.id
|
||||
)
|
||||
|
||||
if (!hasAccess || !workflowRecord) {
|
||||
return createErrorResponse('Workflow not found or access denied', 404)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckSession, mockCheckAccess, mockCheckWriteAccess, mockDbChain } = vi.hoisted(() => {
|
||||
const chain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockResolvedValue([]),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
return {
|
||||
mockCheckSession: vi.fn(),
|
||||
mockCheckAccess: vi.fn(),
|
||||
mockCheckWriteAccess: vi.fn(),
|
||||
mockDbChain: chain,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: mockDbChain }))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
document: {
|
||||
id: 'id',
|
||||
connectorId: 'connectorId',
|
||||
deletedAt: 'deletedAt',
|
||||
filename: 'filename',
|
||||
externalId: 'externalId',
|
||||
sourceUrl: 'sourceUrl',
|
||||
enabled: 'enabled',
|
||||
userExcluded: 'userExcluded',
|
||||
uploadedAt: 'uploadedAt',
|
||||
processingStatus: 'processingStatus',
|
||||
},
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: mockCheckAccess,
|
||||
checkKnowledgeBaseWriteAccess: mockCheckWriteAccess,
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSession,
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
|
||||
import { GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
|
||||
describe('Connector Documents API Route', () => {
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockResolvedValue([])
|
||||
mockDbChain.limit.mockResolvedValue([])
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
mockDbChain.returning.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns documents list on success', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false }
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.orderBy.mockResolvedValueOnce([doc])
|
||||
|
||||
const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents'
|
||||
const req = createMockRequest('GET', undefined, undefined, url)
|
||||
Object.assign(req, { nextUrl: new URL(url) })
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.documents).toHaveLength(1)
|
||||
expect(data.data.counts.active).toBe(1)
|
||||
expect(data.data.counts.excluded).toBe(0)
|
||||
})
|
||||
|
||||
it('includes excluded documents when includeExcluded=true', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.orderBy
|
||||
.mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }])
|
||||
.mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }])
|
||||
|
||||
const url =
|
||||
'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true'
|
||||
const req = createMockRequest('GET', undefined, undefined, url)
|
||||
Object.assign(req, { nextUrl: new URL(url) })
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.documents).toHaveLength(2)
|
||||
expect(data.data.counts.active).toBe(1)
|
||||
expect(data.data.counts.excluded).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid body', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
|
||||
const req = createMockRequest('PATCH', { documentIds: [] })
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns success for restore operation', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.restoredCount).toBe(1)
|
||||
})
|
||||
|
||||
it('returns success for exclude operation', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
|
||||
|
||||
const req = createMockRequest('PATCH', {
|
||||
operation: 'exclude',
|
||||
documentIds: ['doc-2', 'doc-3'],
|
||||
})
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.excludedCount).toBe(2)
|
||||
expect(data.data.documentIds).toEqual(['doc-2', 'doc-3'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { db } from '@sim/db'
|
||||
import { document, knowledgeConnector } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('ConnectorDocumentsAPI')
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/[id]/connectors/[connectorId]/documents
|
||||
* Returns documents for a connector, optionally including user-excluded ones.
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectorRows = await db
|
||||
.select({ id: knowledgeConnector.id })
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (connectorRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const includeExcluded = request.nextUrl.searchParams.get('includeExcluded') === 'true'
|
||||
|
||||
const activeDocs = await db
|
||||
.select({
|
||||
id: document.id,
|
||||
filename: document.filename,
|
||||
externalId: document.externalId,
|
||||
sourceUrl: document.sourceUrl,
|
||||
enabled: document.enabled,
|
||||
userExcluded: document.userExcluded,
|
||||
uploadedAt: document.uploadedAt,
|
||||
processingStatus: document.processingStatus,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
isNull(document.deletedAt),
|
||||
eq(document.userExcluded, false)
|
||||
)
|
||||
)
|
||||
.orderBy(document.filename)
|
||||
|
||||
const excludedDocs = includeExcluded
|
||||
? await db
|
||||
.select({
|
||||
id: document.id,
|
||||
filename: document.filename,
|
||||
externalId: document.externalId,
|
||||
sourceUrl: document.sourceUrl,
|
||||
enabled: document.enabled,
|
||||
userExcluded: document.userExcluded,
|
||||
uploadedAt: document.uploadedAt,
|
||||
processingStatus: document.processingStatus,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
eq(document.userExcluded, true),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(document.filename)
|
||||
: []
|
||||
|
||||
const docs = [...activeDocs, ...excludedDocs]
|
||||
const activeCount = activeDocs.length
|
||||
const excludedCount = excludedDocs.length
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
documents: docs,
|
||||
counts: { active: activeCount, excluded: excludedCount },
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching connector documents`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const PatchSchema = z.object({
|
||||
operation: z.enum(['restore', 'exclude']),
|
||||
documentIds: z.array(z.string()).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /api/knowledge/[id]/connectors/[connectorId]/documents
|
||||
* Restore or exclude connector documents.
|
||||
*/
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectorRows = await db
|
||||
.select({ id: knowledgeConnector.id })
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (connectorRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = PatchSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { operation, documentIds } = parsed.data
|
||||
|
||||
if (operation === 'restore') {
|
||||
const updated = await db
|
||||
.update(document)
|
||||
.set({ userExcluded: false, deletedAt: null, enabled: true })
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
inArray(document.id, documentIds),
|
||||
eq(document.userExcluded, true),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: document.id })
|
||||
|
||||
logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) },
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(document)
|
||||
.set({ userExcluded: true })
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
inArray(document.id, documentIds),
|
||||
eq(document.userExcluded, false),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: document.id })
|
||||
|
||||
logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating connector documents`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckSession, mockCheckAccess, mockCheckWriteAccess, mockDbChain, mockValidateConfig } =
|
||||
vi.hoisted(() => {
|
||||
const chain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
return {
|
||||
mockCheckSession: vi.fn(),
|
||||
mockCheckAccess: vi.fn(),
|
||||
mockCheckWriteAccess: vi.fn(),
|
||||
mockDbChain: chain,
|
||||
mockValidateConfig: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: mockDbChain }))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
document: { connectorId: 'connectorId', deletedAt: 'deletedAt' },
|
||||
knowledgeBase: { id: 'id', userId: 'userId' },
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
connectorType: 'connectorType',
|
||||
credentialId: 'credentialId',
|
||||
},
|
||||
knowledgeConnectorSyncLog: { connectorId: 'connectorId', startedAt: 'startedAt' },
|
||||
}))
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: mockCheckAccess,
|
||||
checkKnowledgeBaseWriteAccess: mockCheckWriteAccess,
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSession,
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
refreshAccessTokenIfNeeded: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/connectors/registry', () => ({
|
||||
CONNECTOR_REGISTRY: {
|
||||
jira: { validateConfig: mockValidateConfig },
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||
cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route'
|
||||
|
||||
describe('Knowledge Connector By ID API Route', () => {
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockReturnThis()
|
||||
mockDbChain.limit.mockResolvedValue([])
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
mockDbChain.returning.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when KB not found', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckAccess.mockResolvedValue({ hasAccess: false, notFound: true })
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns connector with sync logs on success', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' }
|
||||
const mockLogs = [{ id: 'log-1', status: 'completed' }]
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs)
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.id).toBe('conn-456')
|
||||
expect(data.data.syncLogs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('PATCH', { status: 'paused' })
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid body', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('Invalid request')
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found during sourceConfig validation', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 200 and updates status', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
|
||||
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
|
||||
|
||||
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.status).toBe('paused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const response = await DELETE(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 200 on successful soft-delete', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const response = await DELETE(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
document,
|
||||
knowledgeBase,
|
||||
knowledgeConnector,
|
||||
knowledgeConnectorSyncLog,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { decryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
|
||||
const logger = createLogger('KnowledgeConnectorByIdAPI')
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
|
||||
|
||||
const UpdateConnectorSchema = z.object({
|
||||
sourceConfig: z.record(z.unknown()).optional(),
|
||||
syncIntervalMinutes: z.number().int().min(0).optional(),
|
||||
status: z.enum(['active', 'paused']).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectorRows = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (connectorRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const syncLogs = await db
|
||||
.select()
|
||||
.from(knowledgeConnectorSyncLog)
|
||||
.where(eq(knowledgeConnectorSyncLog.connectorId, connectorId))
|
||||
.orderBy(desc(knowledgeConnectorSyncLog.startedAt))
|
||||
.limit(10)
|
||||
|
||||
const { encryptedApiKey: _, ...connectorData } = connectorRows[0]
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...connectorData,
|
||||
syncLogs,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
|
||||
*/
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = UpdateConnectorSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (parsed.data.sourceConfig !== undefined) {
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const existing = existingRows[0]
|
||||
const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]
|
||||
|
||||
if (!connectorConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown connector type: ${existing.connectorType}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const kbRows = await db
|
||||
.select({ userId: knowledgeBase.userId })
|
||||
.from(knowledgeBase)
|
||||
.where(eq(knowledgeBase.id, knowledgeBaseId))
|
||||
.limit(1)
|
||||
|
||||
if (kbRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let accessToken: string | null = null
|
||||
if (connectorConfig.auth.mode === 'apiKey') {
|
||||
if (!existing.encryptedApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key not found. Please reconfigure the connector.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted
|
||||
} else {
|
||||
if (!existing.credentialId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OAuth credential not found. Please reconfigure the connector.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
accessToken = await refreshAccessTokenIfNeeded(
|
||||
existing.credentialId,
|
||||
kbRows[0].userId,
|
||||
`patch-${connectorId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to refresh access token. Please reconnect your account.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const validation = await connectorConfig.validateConfig(accessToken, parsed.data.sourceConfig)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: validation.error || 'Invalid source configuration' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (parsed.data.sourceConfig !== undefined) {
|
||||
updates.sourceConfig = parsed.data.sourceConfig
|
||||
}
|
||||
if (parsed.data.syncIntervalMinutes !== undefined) {
|
||||
updates.syncIntervalMinutes = parsed.data.syncIntervalMinutes
|
||||
if (parsed.data.syncIntervalMinutes > 0) {
|
||||
updates.nextSyncAt = new Date(Date.now() + parsed.data.syncIntervalMinutes * 60 * 1000)
|
||||
} else {
|
||||
updates.nextSyncAt = null
|
||||
}
|
||||
}
|
||||
if (parsed.data.status !== undefined) {
|
||||
updates.status = parsed.data.status
|
||||
}
|
||||
|
||||
await db
|
||||
.update(knowledgeConnector)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const { encryptedApiKey: __, ...updatedData } = updated[0]
|
||||
return NextResponse.json({ success: true, data: updatedData })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/knowledge/[id]/connectors/[connectorId] - Soft-delete a connector
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(knowledgeConnector)
|
||||
.set({ deletedAt: now, status: 'paused', updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
// Soft-delete all documents belonging to this connector
|
||||
await db
|
||||
.update(document)
|
||||
.set({ deletedAt: now })
|
||||
.where(and(eq(document.connectorId, connectorId), isNull(document.deletedAt)))
|
||||
|
||||
// Reclaim tag slots that are no longer used by any active connector
|
||||
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Soft-deleted connector ${connectorId} and its documents`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckSession, mockCheckWriteAccess, mockDispatchSync, mockDbChain } = vi.hoisted(() => {
|
||||
const chain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockResolvedValue([]),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return {
|
||||
mockCheckSession: vi.fn(),
|
||||
mockCheckWriteAccess: vi.fn(),
|
||||
mockDispatchSync: vi.fn().mockResolvedValue(undefined),
|
||||
mockDbChain: chain,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: mockDbChain }))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
status: 'status',
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseWriteAccess: mockCheckWriteAccess,
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSession,
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
|
||||
dispatchSync: mockDispatchSync,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route'
|
||||
|
||||
describe('Connector Manual Sync API Route', () => {
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockResolvedValue([])
|
||||
mockDbChain.limit.mockResolvedValue([])
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when connector is syncing', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
})
|
||||
|
||||
it('dispatches sync on valid request', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
235
apps/sim/app/api/knowledge/[id]/connectors/route.ts
Normal file
235
apps/sim/app/api/knowledge/[id]/connectors/route.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { db } from '@sim/db'
|
||||
import { knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { encryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
import { allocateTagSlots } from '@/lib/knowledge/constants'
|
||||
import { createTagDefinition } from '@/lib/knowledge/tags/service'
|
||||
import { getCredential } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
|
||||
const logger = createLogger('KnowledgeConnectorsAPI')
|
||||
|
||||
const CreateConnectorSchema = z.object({
|
||||
connectorType: z.string().min(1),
|
||||
credentialId: z.string().min(1).optional(),
|
||||
apiKey: z.string().min(1).optional(),
|
||||
sourceConfig: z.record(z.unknown()),
|
||||
syncIntervalMinutes: z.number().int().min(0).default(1440),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/[id]/connectors - List connectors for a knowledge base
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectors = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(knowledgeConnector.createdAt))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: connectors.map(({ encryptedApiKey: _, ...rest }) => rest),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing connectors`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/knowledge/[id]/connectors - Create a new connector
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = CreateConnectorSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data
|
||||
|
||||
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
|
||||
if (!connectorConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown connector type: ${connectorType}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let resolvedCredentialId: string | null = null
|
||||
let resolvedEncryptedApiKey: string | null = null
|
||||
let accessToken: string
|
||||
|
||||
if (connectorConfig.auth.mode === 'apiKey') {
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'API key is required' }, { status: 400 })
|
||||
}
|
||||
accessToken = apiKey
|
||||
} else {
|
||||
if (!credentialId) {
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, auth.userId)
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!credential.accessToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential has no access token. Please reconnect your account.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
accessToken = credential.accessToken
|
||||
resolvedCredentialId = credentialId
|
||||
}
|
||||
|
||||
const validation = await connectorConfig.validateConfig(accessToken, sourceConfig)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: validation.error || 'Invalid source configuration' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let finalSourceConfig: Record<string, unknown> = { ...sourceConfig }
|
||||
|
||||
if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
|
||||
const { encrypted } = await encryptApiKey(apiKey)
|
||||
resolvedEncryptedApiKey = encrypted
|
||||
}
|
||||
|
||||
const tagSlotMapping: Record<string, string> = {}
|
||||
|
||||
if (connectorConfig.tagDefinitions?.length) {
|
||||
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
|
||||
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
|
||||
|
||||
const existingDefs = await db
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
|
||||
Object.assign(tagSlotMapping, mapping)
|
||||
|
||||
for (const name of skippedTags) {
|
||||
logger.warn(`[${requestId}] No available slots for "${name}"`)
|
||||
}
|
||||
|
||||
if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
finalSourceConfig = { ...finalSourceConfig, tagSlotMapping }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const connectorId = crypto.randomUUID()
|
||||
const nextSyncAt =
|
||||
syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const [semanticId, slot] of Object.entries(tagSlotMapping)) {
|
||||
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
|
||||
await createTagDefinition(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
tagSlot: slot,
|
||||
displayName: td.displayName,
|
||||
fieldType: td.fieldType,
|
||||
},
|
||||
requestId,
|
||||
tx
|
||||
)
|
||||
}
|
||||
|
||||
await tx.insert(knowledgeConnector).values({
|
||||
id: connectorId,
|
||||
knowledgeBaseId,
|
||||
connectorType,
|
||||
credentialId: resolvedCredentialId,
|
||||
encryptedApiKey: resolvedEncryptedApiKey,
|
||||
sourceConfig: finalSourceConfig,
|
||||
syncIntervalMinutes,
|
||||
status: 'active',
|
||||
nextSyncAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
|
||||
|
||||
dispatchSync(connectorId, { requestId }).catch((error) => {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
|
||||
error
|
||||
)
|
||||
})
|
||||
|
||||
const created = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(eq(knowledgeConnector.id, connectorId))
|
||||
.limit(1)
|
||||
|
||||
const { encryptedApiKey: _, ...createdData } = created[0]
|
||||
return NextResponse.json({ success: true, data: createdData }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user