mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
89 Commits
main
...
waleedlati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3c5b5b1c | ||
|
|
ea6692517b | ||
|
|
9e0c41df8c | ||
|
|
a368827f1e | ||
|
|
337154054e | ||
|
|
c6ac0b4445 | ||
|
|
b07925fcc0 | ||
|
|
08fb8c1651 | ||
|
|
37337aece5 | ||
|
|
da349176ab | ||
|
|
6f3559ce8f | ||
|
|
9a7b5ffe64 | ||
|
|
4ede071ecb | ||
|
|
161fb37244 | ||
|
|
d1575927a2 | ||
|
|
a3b19fb32a | ||
|
|
21404d17e8 | ||
|
|
df7e731c9c | ||
|
|
4f4191fe1b | ||
|
|
b57636e5b1 | ||
|
|
38c9ecd259 | ||
|
|
fadda6aaef | ||
|
|
82f541e9de | ||
|
|
1339915957 | ||
|
|
7fafc00a07 | ||
|
|
fe5ab8aee8 | ||
|
|
b3a639a693 | ||
|
|
0249ca1480 | ||
|
|
553c376289 | ||
|
|
4622966643 | ||
|
|
e9550c624d | ||
|
|
1d48289c53 | ||
|
|
fce10241a5 | ||
|
|
ae080f125c | ||
|
|
0fb840c8fd | ||
|
|
2c20519bbd | ||
|
|
f3474b0c90 | ||
|
|
b2cc5b6738 | ||
|
|
d49a2c1c25 | ||
|
|
8fa4745893 | ||
|
|
c168e36a05 | ||
|
|
9cc46ffa43 | ||
|
|
cc5e592c46 | ||
|
|
7276136398 | ||
|
|
3ad7af4b97 | ||
|
|
3cb1768a44 | ||
|
|
11e6387a7d | ||
|
|
57a91027de | ||
|
|
49c29d5f7d | ||
|
|
843af915bc | ||
|
|
bb3e899f74 | ||
|
|
e47dcdcc43 | ||
|
|
3e6cf24762 | ||
|
|
90a12546b2 | ||
|
|
b6f8439267 | ||
|
|
4f74a8b845 | ||
|
|
f12d8f631f | ||
|
|
41f0957ccc | ||
|
|
7b813be1dd | ||
|
|
704fa16bb4 | ||
|
|
eccad2a8ce | ||
|
|
87f5c464d9 | ||
|
|
724aaa1432 | ||
|
|
3de3ef4786 | ||
|
|
743f048442 | ||
|
|
bbcf346df0 | ||
|
|
b9c3c2f78f | ||
|
|
d333307a17 | ||
|
|
134c4c4f2a | ||
|
|
03908edcbb | ||
|
|
3112485c31 | ||
|
|
459c2930ae | ||
|
|
3338b25c30 | ||
|
|
4c3002f97d | ||
|
|
632e0e0762 | ||
|
|
7599774974 | ||
|
|
471e58a2d0 | ||
|
|
231ddc59a0 | ||
|
|
b197f68828 | ||
|
|
4453edbaa8 | ||
|
|
f0c3392e3f | ||
|
|
74db11c0fe | ||
|
|
e207ad2502 | ||
|
|
f5640b2919 | ||
|
|
b0d9f4e8b5 | ||
|
|
e7c143bdcc | ||
|
|
6fcc820914 | ||
|
|
82b874c027 | ||
|
|
3eaf5babf4 |
261
.claude/commands/add-connector.md
Normal file
261
.claude/commands/add-connector.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
description: Add a knowledge base connector for syncing documents from an external source
|
||||
argument-hint: <service-name> [api-docs-url]
|
||||
---
|
||||
|
||||
# Add Connector Skill
|
||||
|
||||
You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base.
|
||||
|
||||
## Your Task
|
||||
|
||||
When the user asks you to create a connector:
|
||||
1. Use Context7 or WebFetch to read the service's API documentation
|
||||
2. Create the connector directory and config
|
||||
3. Register it in the connector registry
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Create files in `apps/sim/connectors/{service}/`:
|
||||
```
|
||||
connectors/{service}/
|
||||
├── index.ts # Barrel export
|
||||
└── {service}.ts # ConnectorConfig definition
|
||||
```
|
||||
|
||||
## ConnectorConfig Structure
|
||||
|
||||
```typescript
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { {Service}Icon } from '@/components/icons'
|
||||
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
|
||||
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
||||
|
||||
const logger = createLogger('{Service}Connector')
|
||||
|
||||
export const {service}Connector: ConnectorConfig = {
|
||||
id: '{service}',
|
||||
name: '{Service}',
|
||||
description: 'Sync documents from {Service} into your knowledge base',
|
||||
version: '1.0.0',
|
||||
icon: {Service}Icon,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: '{service}', // Must match OAuthService in lib/oauth/types.ts
|
||||
requiredScopes: ['read:...'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
// Rendered dynamically by the add-connector modal UI
|
||||
// Supports 'short-input' and 'dropdown' types
|
||||
],
|
||||
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => {
|
||||
// Paginate via cursor, extract text, compute SHA-256 hash
|
||||
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
|
||||
},
|
||||
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
// Return ExternalDocument or null
|
||||
},
|
||||
|
||||
validateConfig: async (accessToken, sourceConfig) => {
|
||||
// Return { valid: true } or { valid: false, error: 'message' }
|
||||
},
|
||||
|
||||
// Optional: map source metadata to semantic tag keys (translated to slots by sync engine)
|
||||
mapTags: (metadata) => {
|
||||
// Return Record<string, unknown> with keys matching tagDefinitions[].id
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## ConfigField Types
|
||||
|
||||
The add-connector modal renders these automatically — no custom UI needed.
|
||||
|
||||
```typescript
|
||||
// Text input
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'yoursite.example.com',
|
||||
required: true,
|
||||
}
|
||||
|
||||
// Dropdown (static options)
|
||||
{
|
||||
id: 'contentType',
|
||||
title: 'Content Type',
|
||||
type: 'dropdown',
|
||||
required: false,
|
||||
options: [
|
||||
{ label: 'Pages only', id: 'page' },
|
||||
{ label: 'Blog posts only', id: 'blogpost' },
|
||||
{ label: 'All content', id: 'all' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## ExternalDocument Shape
|
||||
|
||||
Every document returned from `listDocuments`/`getDocument` must include:
|
||||
|
||||
```typescript
|
||||
{
|
||||
externalId: string // Source-specific unique ID
|
||||
title: string // Document title
|
||||
content: string // Extracted plain text
|
||||
mimeType: 'text/plain' // Always text/plain (content is extracted)
|
||||
contentHash: string // SHA-256 of content (change detection)
|
||||
sourceUrl?: string // Link back to original (stored on document record)
|
||||
metadata?: Record<string, unknown> // Source-specific data (fed to mapTags)
|
||||
}
|
||||
```
|
||||
|
||||
## Content Hashing (Required)
|
||||
|
||||
The sync engine uses content hashes for change detection:
|
||||
|
||||
```typescript
|
||||
async function computeContentHash(content: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(content)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
```
|
||||
|
||||
## tagDefinitions — Declared Tag Definitions
|
||||
|
||||
Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
|
||||
On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names.
|
||||
|
||||
```typescript
|
||||
tagDefinitions: [
|
||||
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
|
||||
{ id: 'version', displayName: 'Version', fieldType: 'number' },
|
||||
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
|
||||
],
|
||||
```
|
||||
|
||||
Each entry has:
|
||||
- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`)
|
||||
- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified")
|
||||
- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from
|
||||
|
||||
Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`.
|
||||
The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`.
|
||||
|
||||
## mapTags — Metadata to Semantic Keys
|
||||
|
||||
Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set.
|
||||
The sync engine calls this automatically and translates semantic keys to actual DB slots
|
||||
using the `tagSlotMapping` stored on the connector.
|
||||
|
||||
Return keys must match the `id` values declared in `tagDefinitions`.
|
||||
|
||||
```typescript
|
||||
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
// Validate arrays before casting — metadata may be malformed
|
||||
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
|
||||
if (labels.length > 0) result.labels = labels.join(', ')
|
||||
|
||||
// Validate numbers — guard against NaN
|
||||
if (metadata.version != null) {
|
||||
const num = Number(metadata.version)
|
||||
if (!Number.isNaN(num)) result.version = num
|
||||
}
|
||||
|
||||
// Validate dates — guard against Invalid Date
|
||||
if (typeof metadata.lastModified === 'string') {
|
||||
const date = new Date(metadata.lastModified)
|
||||
if (!Number.isNaN(date.getTime())) result.lastModified = date
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## External API Calls — Use `fetchWithRetry`
|
||||
|
||||
All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged.
|
||||
|
||||
For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max).
|
||||
|
||||
```typescript
|
||||
import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils'
|
||||
|
||||
// Background sync — use defaults
|
||||
const response = await fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
// validateConfig — tighter retry budget
|
||||
const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS)
|
||||
```
|
||||
|
||||
## sourceUrl
|
||||
|
||||
If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path).
|
||||
|
||||
## Sync Engine Behavior (Do Not Modify)
|
||||
|
||||
The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It:
|
||||
1. Calls `listDocuments` with pagination until `hasMore` is false
|
||||
2. Compares `contentHash` to detect new/changed/unchanged documents
|
||||
3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically
|
||||
4. Handles soft-delete of removed documents
|
||||
|
||||
You never need to modify the sync engine when adding a connector.
|
||||
|
||||
## OAuth Credential Reuse
|
||||
|
||||
Connectors reuse the existing OAuth infrastructure. The `oauth.provider` must match an `OAuthService` from `apps/sim/lib/oauth/types.ts`. Check existing providers before adding a new one.
|
||||
|
||||
## Icon
|
||||
|
||||
The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain.
|
||||
|
||||
If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG.
|
||||
|
||||
## Registering
|
||||
|
||||
Add one line to `apps/sim/connectors/registry.ts`:
|
||||
|
||||
```typescript
|
||||
import { {service}Connector } from '@/connectors/{service}'
|
||||
|
||||
export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
// ... existing connectors ...
|
||||
{service}: {service}Connector,
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
See `apps/sim/connectors/confluence/confluence.ts` for a complete example with:
|
||||
- Multiple config field types (text + dropdown)
|
||||
- Label fetching and CQL search filtering
|
||||
- Blogpost + page content types
|
||||
- `mapTags` mapping labels, version, and dates to semantic keys
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig
|
||||
- [ ] Created `connectors/{service}/index.ts` barrel export
|
||||
- [ ] `oauth.provider` matches an existing OAuthService in `lib/oauth/types.ts`
|
||||
- [ ] `listDocuments` handles pagination and computes content hashes
|
||||
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
|
||||
- [ ] `metadata` includes source-specific data for tag mapping
|
||||
- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags`
|
||||
- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions)
|
||||
- [ ] `validateConfig` verifies the source is accessible
|
||||
- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`)
|
||||
- [ ] All optional config fields validated in `validateConfig`
|
||||
- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG)
|
||||
- [ ] Registered in `connectors/registry.ts`
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -367,15 +367,17 @@ export async function generateMetadata(props: {
|
||||
return {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
keywords: [
|
||||
'AI workflow builder',
|
||||
'visual workflow editor',
|
||||
'AI automation',
|
||||
'workflow automation',
|
||||
'AI agents',
|
||||
'no-code AI',
|
||||
'drag and drop workflows',
|
||||
'agentic workforce',
|
||||
'AI agent platform',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI automation',
|
||||
'knowledge base',
|
||||
'AI integrations',
|
||||
data.title?.toLowerCase().split(' '),
|
||||
]
|
||||
.flat()
|
||||
@@ -385,7 +387,8 @@ export async function generateMetadata(props: {
|
||||
openGraph: {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
url: fullUrl,
|
||||
siteName: 'Sim Documentation',
|
||||
type: 'article',
|
||||
@@ -406,7 +409,8 @@ export async function generateMetadata(props: {
|
||||
card: 'summary_large_image',
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
images: [ogImageUrl],
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,8 @@
|
||||
"(generated)/workflows",
|
||||
"(generated)/logs",
|
||||
"(generated)/usage",
|
||||
"(generated)/audit-logs"
|
||||
"(generated)/audit-logs",
|
||||
"(generated)/tables",
|
||||
"(generated)/files"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"(generated)/workflows",
|
||||
"(generated)/logs",
|
||||
"(generated)/usage",
|
||||
"(generated)/audit-logs"
|
||||
"(generated)/audit-logs",
|
||||
"(generated)/tables",
|
||||
"(generated)/files"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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,226 @@
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Tool entry displayed as a chip on agent blocks
|
||||
*/
|
||||
export interface PreviewTool {
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Static block definition for preview workflow nodes
|
||||
*/
|
||||
export interface PreviewBlock {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
tools?: PreviewTool[]
|
||||
markdown?: string
|
||||
position: { x: number; y: number }
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow definition containing nodes, edges, and metadata
|
||||
*/
|
||||
export interface PreviewWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
blocks: PreviewBlock[]
|
||||
edges: Array<{ id: string; source: string; target: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* IT Service Management workflow — Slack Trigger -> Agent (KB tool) -> Jira
|
||||
*/
|
||||
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-it-service',
|
||||
name: 'IT Service Management',
|
||||
color: '#FF6B2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#it-support' },
|
||||
{ title: 'Event', value: 'New Message' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Triage incoming IT...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#10B981' }],
|
||||
position: { x: 420, y: 40 },
|
||||
},
|
||||
{
|
||||
id: 'jira-1',
|
||||
name: 'Jira',
|
||||
type: 'jira',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Get Issues' },
|
||||
{ title: 'Project', value: 'IT-Support' },
|
||||
],
|
||||
position: { x: 420, y: 260 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'slack-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'slack-1', target: 'jira-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
|
||||
*/
|
||||
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-content-pipeline',
|
||||
name: 'Content Pipeline',
|
||||
color: '#33C482',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||
],
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty "New Agent" workflow — a single note prompting the user to start building
|
||||
*/
|
||||
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-new-agent',
|
||||
name: 'New Agent',
|
||||
color: '#787878',
|
||||
blocks: [
|
||||
{
|
||||
id: 'note-1',
|
||||
name: '',
|
||||
type: 'note',
|
||||
bgColor: 'transparent',
|
||||
rows: [],
|
||||
markdown: '### What will you build?\n\n_"Find Linear todos and send in Slack"_',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
CONTENT_PIPELINE_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
/** Stagger delay between each block appearing (seconds). */
|
||||
export const BLOCK_STAGGER = 0.12
|
||||
|
||||
/** Shared cubic-bezier easing — fast deceleration, gentle settle. */
|
||||
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||
|
||||
/** Shared edge style applied to all preview workflow connections */
|
||||
const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
|
||||
|
||||
/**
|
||||
* Converts a PreviewWorkflow to React Flow nodes and edges.
|
||||
*
|
||||
* @param workflow - The workflow definition
|
||||
* @param animate - When true, node/edge data includes animation metadata
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false
|
||||
): {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
} {
|
||||
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => ({
|
||||
id: block.id,
|
||||
type: 'previewBlock',
|
||||
position: block.position,
|
||||
data: {
|
||||
name: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
tools: block.tools,
|
||||
markdown: block.markdown,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
hideSourceHandle: block.hideSourceHandle,
|
||||
index,
|
||||
animate,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}))
|
||||
|
||||
const edges: Edge[] = workflow.edges.map((e) => {
|
||||
const sourceIndex = blockIndexMap.get(e.source) ?? 0
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: EDGE_STYLE,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
218
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
218
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
|
||||
interface PricingTier {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
billingPeriod?: string
|
||||
color: string
|
||||
features: string[]
|
||||
cta: { label: string; href: string }
|
||||
}
|
||||
|
||||
const PRICING_TIERS: PricingTier[] = [
|
||||
{
|
||||
id: 'community',
|
||||
name: 'Community',
|
||||
description: 'For individuals getting started with AI agents',
|
||||
price: 'Free',
|
||||
color: '#2ABBF8',
|
||||
features: [
|
||||
'$20 usage limit',
|
||||
'5GB file storage',
|
||||
'5 min execution limit',
|
||||
'Limited log retention',
|
||||
'CLI/SDK Access',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'professional',
|
||||
name: 'Professional',
|
||||
description: 'For professionals building production workflows',
|
||||
price: '$20',
|
||||
billingPeriod: 'per month',
|
||||
color: '#00F701',
|
||||
features: [
|
||||
'150 runs per minute (sync)',
|
||||
'1,000 runs per minute (async)',
|
||||
'50 min sync execution limit',
|
||||
'50GB file storage',
|
||||
'Unlimited invites',
|
||||
'Unlimited log retention',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
name: 'Team',
|
||||
description: 'For teams collaborating on complex agents',
|
||||
price: '$40',
|
||||
billingPeriod: 'per month',
|
||||
color: '#FA4EDF',
|
||||
features: [
|
||||
'300 runs per minute (sync)',
|
||||
'2,500 runs per minute (async)',
|
||||
'500GB file storage (pooled)',
|
||||
'50 min sync execution limit',
|
||||
'Unlimited invites',
|
||||
'Unlimited log retention',
|
||||
'Dedicated Slack channel',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'For organizations needing security and scale',
|
||||
price: 'Custom',
|
||||
color: '#FFCC02',
|
||||
features: ['Custom rate limits', 'Custom file storage', 'SSO', 'SOC2', 'Dedicated support'],
|
||||
cta: { label: 'Book a demo', href: '/contact' },
|
||||
},
|
||||
]
|
||||
|
||||
function CheckIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
|
||||
<path
|
||||
d='M2.5 7L5.5 10L11.5 4'
|
||||
stroke={color}
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface PricingCardProps {
|
||||
tier: PricingTier
|
||||
}
|
||||
|
||||
function PricingCard({ tier }: PricingCardProps) {
|
||||
const isEnterprise = tier.id === 'enterprise'
|
||||
const isProfessional = tier.id === 'professional'
|
||||
|
||||
return (
|
||||
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
|
||||
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[#E5E5E5] border-b-0 bg-white p-5'>
|
||||
<div className='flex flex-col'>
|
||||
<h3
|
||||
id={`${tier.id}-heading`}
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<p className='mt-2 min-h-[44px] font-[430] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className='mt-4 flex items-center gap-1.5 font-[430] font-season text-[#1C1C1C] text-[20px] leading-[100%] tracking-[-0.02em]'>
|
||||
{tier.price}
|
||||
{tier.billingPeriod && (
|
||||
<span className='text-[#737373] text-[16px]'>{tier.billingPeriod}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
{isEnterprise ? (
|
||||
<a
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</a>
|
||||
) : isProfessional ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className='flex flex-col gap-2'>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className='flex items-center gap-2'>
|
||||
<CheckIcon color='#404040' />
|
||||
<span className='font-[400] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='relative h-[6px]'>
|
||||
<div
|
||||
className='absolute inset-0 rounded-b-sm opacity-60'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 left-[12%] rounded-b-sm opacity-60'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 left-[25%] rounded-b-sm'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing section — tiered pricing plans with feature comparison.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="pricing" aria-labelledby="pricing-heading">`.
|
||||
* - `<h2 id="pricing-heading">` for the section title.
|
||||
* - Each tier: `<h3>` plan name + semantic `<ul>` feature list.
|
||||
* - Free tier CTA uses `<Link href="/signup">` (crawlable). Enterprise CTA uses `<a>`.
|
||||
*
|
||||
* GEO:
|
||||
* - Each plan has consistent structure: name, price, billing period, feature list.
|
||||
* - Lead with a summary: "Sim offers a free Community plan, $20/mo Pro, $40/mo Team, custom Enterprise."
|
||||
* - Prices must match the `Offer` items in structured-data.tsx exactly.
|
||||
*/
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#2ABBF8]/10 font-season text-[#2ABBF8] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Pricing
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='pricing-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Pricing
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{PRICING_TIERS.map((tier) => (
|
||||
<PricingCard key={tier.id} tier={tier} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
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',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Pro Plan',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Team Plan',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
],
|
||||
featureList: [
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
'Event triggers',
|
||||
],
|
||||
review: [
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'Hasan Toor' },
|
||||
reviewBody:
|
||||
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
|
||||
url: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'nizzy' },
|
||||
reviewBody:
|
||||
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
|
||||
url: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Organization', name: 'xyflow' },
|
||||
reviewBody: 'A very good looking agent workflow builder and open source!',
|
||||
url: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://sim.ai/#faq',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Which AI models does Sim support?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim supports all major AI models including OpenAI (GPT-5, GPT-4o), Anthropic (Claude), Google (Gemini), xAI (Grok), Mistral, Perplexity, and many more. You can also connect to open-source models via Ollama.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How much does Sim cost?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers a free Community plan with $20 usage limit, a Pro plan at $20/month, a Team plan at $40/month, and custom Enterprise pricing. All plans include CLI/SDK access.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need coding skills to use Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What enterprise features does Sim offer?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
582
apps/sim/app/(home)/components/templates/template-workflows.ts
Normal file
582
apps/sim/app/(home)/components/templates/template-workflows.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* OCR Invoice to DB — Start → Agent (Textract) → Supabase
|
||||
* Pattern: Straight line (all blocks aligned at top)
|
||||
*/
|
||||
const OCR_INVOICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-ocr-invoice',
|
||||
name: 'OCR Invoice to DB',
|
||||
color: '#2ABBF8',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'URL', value: 'invoice.pdf' }],
|
||||
position: { x: 40, y: 80 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Extract invoice fields...' },
|
||||
],
|
||||
tools: [{ name: 'Textract', type: 'textract', bgColor: '#055F4E' }],
|
||||
position: { x: 400, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'supabase-1',
|
||||
name: 'Supabase',
|
||||
type: 'supabase',
|
||||
bgColor: '#1C1C1C',
|
||||
rows: [
|
||||
{ title: 'Table', value: 'invoices' },
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
],
|
||||
position: { x: 760, y: 80 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'agent-1', target: 'supabase-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Release Agent — GitHub → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const GITHUB_RELEASE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-github-release',
|
||||
name: 'GitHub Release Agent',
|
||||
color: '#00F701',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-1',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Release' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Summarize changelog...' },
|
||||
],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#releases' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-1', target: 'agent-2' },
|
||||
{ id: 'e-2', source: 'agent-2', target: 'slack-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting Follow-up Agent — Google Calendar → Agent → Gmail
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const MEETING_FOLLOWUP_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-meeting-followup',
|
||||
name: 'Meeting Follow-up Agent',
|
||||
color: '#FFCC02',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gcal-1',
|
||||
name: 'Google Calendar',
|
||||
type: 'google_calendar',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Meeting Ended' },
|
||||
{ title: 'Calendar', value: 'Work' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-3',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Draft follow-up email...' },
|
||||
],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'gmail-1',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Send Email' },
|
||||
{ title: 'To', value: 'attendees' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gcal-1', target: 'agent-3' },
|
||||
{ id: 'e-2', source: 'agent-3', target: 'gmail-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* CV/Resume Scanner — Start → Agent (Reducto) → Google Sheets
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const CV_SCANNER_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-cv-scanner',
|
||||
name: 'CV/Resume Scanner',
|
||||
color: '#FA4EDF',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-2',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'File URL', value: 'resume.pdf' }],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-4',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-opus-4.6' },
|
||||
{ title: 'System Prompt', value: 'Parse resume fields...' },
|
||||
],
|
||||
tools: [{ name: 'Reducto', type: 'reducto', bgColor: '#5c0c5c' }],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-1',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Candidates' },
|
||||
{ title: 'Operation', value: 'Append Row' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-2', target: 'agent-4' },
|
||||
{ id: 'e-2', source: 'agent-4', target: 'gsheets-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Email Triage Agent — Gmail → Agent (KB) → fan-out to Slack + Linear
|
||||
* Pattern: Fan-out (input low → agent mid → outputs spread vertically)
|
||||
*/
|
||||
const EMAIL_TRIAGE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-email-triage',
|
||||
name: 'Email Triage Agent',
|
||||
color: '#FF6B2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmail-2',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Email' },
|
||||
{ title: 'Label', value: 'Inbox' },
|
||||
],
|
||||
position: { x: 60, y: 130 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-5',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2-mini' },
|
||||
{ title: 'System Prompt', value: 'Classify and route...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'slack-2',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#urgent' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 20 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'linear-1',
|
||||
name: 'Linear',
|
||||
type: 'linear',
|
||||
bgColor: '#5E6AD2',
|
||||
rows: [
|
||||
{ title: 'Project', value: 'Support' },
|
||||
{ title: 'Operation', value: 'Create Issue' },
|
||||
],
|
||||
position: { x: 680, y: 200 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gmail-2', target: 'agent-5' },
|
||||
{ id: 'e-2', source: 'agent-5', target: 'slack-2' },
|
||||
{ id: 'e-3', source: 'agent-5', target: 'linear-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Competitor Monitor — Schedule → Agent (Firecrawl) → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const COMPETITOR_MONITOR_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-competitor-monitor',
|
||||
name: 'Competitor Monitor',
|
||||
color: '#6366F1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '08:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 50 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-6',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Monitor competitor...' },
|
||||
],
|
||||
tools: [{ name: 'Firecrawl', type: 'firecrawl', bgColor: '#181C1E' }],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'slack-3',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#competitive-intel' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 50 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-1', target: 'agent-6' },
|
||||
{ id: 'e-2', source: 'agent-6', target: 'slack-3' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Social Listening Agent — Schedule → Agent (Reddit + X) → Notion
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const SOCIAL_LISTENING_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-social-listening',
|
||||
name: 'Social Listening Agent',
|
||||
color: '#F43F5E',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-2',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [{ title: 'Run Frequency', value: 'Hourly' }],
|
||||
position: { x: 60, y: 150 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-7',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-flash' },
|
||||
{ title: 'System Prompt', value: 'Track brand mentions...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Reddit', type: 'reddit', bgColor: '#FF5700' },
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'notion-1',
|
||||
name: 'Notion',
|
||||
type: 'notion',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Database', value: 'Brand Mentions' },
|
||||
{ title: 'Operation', value: 'Create Page' },
|
||||
],
|
||||
position: { x: 680, y: 150 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-2', target: 'agent-7' },
|
||||
{ id: 'e-2', source: 'agent-7', target: 'notion-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Enrichment Pipeline — Start → Agent (LinkedIn) → Google Sheets
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const DATA_ENRICHMENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-data-enrichment',
|
||||
name: 'Data Enrichment Pipeline',
|
||||
color: '#14B8A6',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-3',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Email', value: 'lead@company.com' }],
|
||||
position: { x: 60, y: 55 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-8',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'mistral-large' },
|
||||
{ title: 'System Prompt', value: 'Enrich lead data...' },
|
||||
],
|
||||
tools: [{ name: 'LinkedIn', type: 'linkedin', bgColor: '#0072B1' }],
|
||||
position: { x: 370, y: 145 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-2',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Lead Database' },
|
||||
{ title: 'Operation', value: 'Update Row' },
|
||||
],
|
||||
position: { x: 680, y: 55 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-3', target: 'agent-8' },
|
||||
{ id: 'e-2', source: 'agent-8', target: 'gsheets-2' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Feedback Digest — Schedule → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const FEEDBACK_DIGEST_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-feedback-digest',
|
||||
name: 'Customer Feedback Digest',
|
||||
color: '#F59E0B',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-3',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-9',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Analyze customer feedback...' },
|
||||
],
|
||||
tools: [{ name: 'Airtable', type: 'airtable', bgColor: '#18BFFF' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-4',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#product-feedback' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-3', target: 'agent-9' },
|
||||
{ id: 'e-2', source: 'agent-9', target: 'slack-4' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* PR Review Agent — GitHub → Agent → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const PR_REVIEW_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-pr-review',
|
||||
name: 'PR Review Agent',
|
||||
color: '#06B6D4',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-2',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Pull Request Opened' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-10',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Review code changes...' },
|
||||
],
|
||||
position: { x: 370, y: 155 },
|
||||
},
|
||||
{
|
||||
id: 'slack-5',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#code-reviews' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-2', target: 'agent-10' },
|
||||
{ id: 'e-2', source: 'agent-10', target: 'slack-5' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge Base QA — Start → Agent (KB) → Response
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const KNOWLEDGE_QA_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-knowledge-qa',
|
||||
name: 'Knowledge Base QA',
|
||||
color: '#84CC16',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-4',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Question', value: 'How do I...' }],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-11',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Answer using knowledge...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'starter-5',
|
||||
name: 'Response',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Answer', value: 'Based on your docs...' }],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-4', target: 'agent-11' },
|
||||
{ id: 'e-2', source: 'agent-11', target: 'starter-5' },
|
||||
],
|
||||
}
|
||||
|
||||
export const TEMPLATE_WORKFLOWS: PreviewWorkflow[] = [
|
||||
OCR_INVOICE_WORKFLOW,
|
||||
GITHUB_RELEASE_WORKFLOW,
|
||||
MEETING_FOLLOWUP_WORKFLOW,
|
||||
CV_SCANNER_WORKFLOW,
|
||||
EMAIL_TRIAGE_WORKFLOW,
|
||||
COMPETITOR_MONITOR_WORKFLOW,
|
||||
SOCIAL_LISTENING_WORKFLOW,
|
||||
DATA_ENRICHMENT_WORKFLOW,
|
||||
FEEDBACK_DIGEST_WORKFLOW,
|
||||
PR_REVIEW_WORKFLOW,
|
||||
KNOWLEDGE_QA_WORKFLOW,
|
||||
]
|
||||
549
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
549
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
|
||||
|
||||
const LandingPreviewWorkflow = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
).then((mod) => mod.LandingPreviewWorkflow),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='h-full w-full bg-[#1b1b1b]' />,
|
||||
}
|
||||
)
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16)
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
const LEFT_WALL_CLIP = 'polygon(0 8px, 100% 0, 100% 100%, 0 100%)'
|
||||
const BOTTOM_WALL_CLIP = 'polygon(0 0, 100% 0, calc(100% - 8px) 100%, 0 100%)'
|
||||
|
||||
interface DepthConfig {
|
||||
color: string
|
||||
segments: readonly (readonly [opacity: number, width: number])[]
|
||||
}
|
||||
|
||||
/** Depth color and gradient segment pattern per template. Segments are `[opacity, width%]` tuples. */
|
||||
const DEPTH_CONFIGS: Record<string, DepthConfig> = {
|
||||
'tpl-ocr-invoice': {
|
||||
color: '#2ABBF8',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.5, 8],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 8],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.9, 7],
|
||||
[0.6, 12],
|
||||
[1, 8],
|
||||
[0.35, 8],
|
||||
],
|
||||
},
|
||||
'tpl-github-release': {
|
||||
color: '#00F701',
|
||||
segments: [
|
||||
[0.4, 8],
|
||||
[0.7, 6],
|
||||
[1, 5],
|
||||
[0.5, 14],
|
||||
[0.85, 8],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 7],
|
||||
[0.45, 8],
|
||||
[1, 8],
|
||||
[0.7, 8],
|
||||
],
|
||||
},
|
||||
'tpl-meeting-followup': {
|
||||
color: '#FFCC02',
|
||||
segments: [
|
||||
[0.5, 12],
|
||||
[0.8, 6],
|
||||
[0.35, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 14],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 8],
|
||||
[1, 6],
|
||||
[0.3, 8],
|
||||
],
|
||||
},
|
||||
'tpl-cv-scanner': {
|
||||
color: '#FA4EDF',
|
||||
segments: [
|
||||
[0.35, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 5],
|
||||
[1, 6],
|
||||
[0.4, 8],
|
||||
[0.75, 12],
|
||||
[0.5, 7],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.8, 8],
|
||||
[0.6, 9],
|
||||
[1, 6],
|
||||
[0.45, 8],
|
||||
],
|
||||
},
|
||||
'tpl-email-triage': {
|
||||
color: '#FF6B2C',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.7, 8],
|
||||
[1, 5],
|
||||
[0.5, 12],
|
||||
[0.85, 6],
|
||||
[0.3, 10],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 12],
|
||||
[1, 8],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-competitor-monitor': {
|
||||
color: '#6366F1',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.55, 10],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 7],
|
||||
[0.9, 8],
|
||||
[0.5, 10],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 6],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
],
|
||||
},
|
||||
'tpl-social-listening': {
|
||||
color: '#F43F5E',
|
||||
segments: [
|
||||
[0.5, 10],
|
||||
[0.8, 6],
|
||||
[0.4, 8],
|
||||
[1, 5],
|
||||
[0.6, 12],
|
||||
[0.35, 8],
|
||||
[0.9, 7],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.75, 8],
|
||||
[0.4, 6],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-data-enrichment': {
|
||||
color: '#14B8A6',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.6, 6],
|
||||
[0.9, 5],
|
||||
[0.4, 12],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 7],
|
||||
[0.85, 8],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.65, 8],
|
||||
[1, 7],
|
||||
[0.5, 8],
|
||||
],
|
||||
},
|
||||
'tpl-feedback-digest': {
|
||||
color: '#F59E0B',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.65, 6],
|
||||
[0.9, 5],
|
||||
[0.5, 12],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 7],
|
||||
[1, 5],
|
||||
[0.6, 10],
|
||||
[0.85, 8],
|
||||
[0.45, 6],
|
||||
[1, 8],
|
||||
[0.55, 9],
|
||||
],
|
||||
},
|
||||
'tpl-pr-review': {
|
||||
color: '#06B6D4',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.7, 7],
|
||||
[1, 5],
|
||||
[0.45, 10],
|
||||
[0.8, 6],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 10],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
[0.5, 7],
|
||||
],
|
||||
},
|
||||
'tpl-knowledge-qa': {
|
||||
color: '#84CC16',
|
||||
segments: [
|
||||
[0.5, 8],
|
||||
[0.75, 6],
|
||||
[0.4, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.85, 7],
|
||||
[0.35, 12],
|
||||
[1, 6],
|
||||
[0.7, 8],
|
||||
[0.45, 10],
|
||||
[0.9, 6],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const SCROLL_BLOCK_RX = '2.59574'
|
||||
|
||||
/**
|
||||
* Two-row horizontal block strip for the scroll-driven reveal in the templates section.
|
||||
* Same structural pattern as the hero's top-right blocks with matching colours:
|
||||
* blue (left) → pink (middle) → green (right).
|
||||
*/
|
||||
const SCROLL_BLOCK_RECTS = [
|
||||
{ opacity: 0.6, x: '-34.24', y: '0', width: '34.24', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '-17.38', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.34', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.24', y: '0', width: '34.24', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.24', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.62', y: '16.86', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.48', y: '0', width: '54.65', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '34.24', height: '33.73', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '51.10', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '123.65', y: '16.86', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '157.37', y: '0', width: '34.24', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.37', y: '0', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.0', y: '0', width: '68.48', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '209.14', y: '0', width: '16.86', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '243.23', y: '0', width: '34.24', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 1, x: '243.23', y: '0', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '260.10', y: '0', width: '34.04', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 1, x: '260.61', y: '16.86', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
] as const
|
||||
|
||||
const SCROLL_BLOCK_MAX_X = Math.max(...SCROLL_BLOCK_RECTS.map((r) => Number.parseFloat(r.x)))
|
||||
const SCROLL_REVEAL_START = 0.05
|
||||
const SCROLL_REVEAL_SPAN = 0.7
|
||||
const SCROLL_FADE_IN = 0.03
|
||||
|
||||
function getScrollBlockThreshold(x: string): number {
|
||||
const normalized = Number.parseFloat(x) / SCROLL_BLOCK_MAX_X
|
||||
return SCROLL_REVEAL_START + (1 - normalized) * SCROLL_REVEAL_SPAN
|
||||
}
|
||||
|
||||
interface ScrollBlockRectProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
rect: (typeof SCROLL_BLOCK_RECTS)[number]
|
||||
}
|
||||
|
||||
/** Renders a single SVG rect whose opacity is driven by scroll progress. */
|
||||
function ScrollBlockRect({ scrollYProgress, rect }: ScrollBlockRectProps) {
|
||||
const threshold = getScrollBlockThreshold(rect.x)
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[threshold, threshold + SCROLL_FADE_IN],
|
||||
[0, rect.opacity]
|
||||
)
|
||||
|
||||
return (
|
||||
<motion.rect
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx={SCROLL_BLOCK_RX}
|
||||
fill={rect.fill}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function buildBottomWallStyle(config: DepthConfig) {
|
||||
let pos = 0
|
||||
const stops: string[] = []
|
||||
for (const [opacity, width] of config.segments) {
|
||||
const c = hexToRgba(config.color, opacity)
|
||||
stops.push(`${c} ${pos}%`, `${c} ${pos + width}%`)
|
||||
pos += width
|
||||
}
|
||||
return {
|
||||
clipPath: BOTTOM_WALL_CLIP,
|
||||
background: `linear-gradient(135deg, ${stops.join(', ')})`,
|
||||
}
|
||||
}
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
rows: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TEMPLATES_PANEL_ID = 'templates-panel'
|
||||
|
||||
export default function Templates() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
|
||||
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id='templates'
|
||||
aria-labelledby='templates-heading'
|
||||
className='mt-[40px] mb-[80px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
|
||||
processing, release management, meeting follow-ups, resume scanning, email triage,
|
||||
competitor monitoring, social listening, data enrichment, feedback analysis, code review,
|
||||
and knowledge base Q&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>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
Use template
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
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>
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
PRO_PLAN_FEATURES,
|
||||
TEAM_PLAN_FEATURES,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
|
||||
|
||||
const logger = createLogger('LandingPricing')
|
||||
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,23 +86,24 @@
|
||||
:root,
|
||||
.light {
|
||||
--bg: #fefefe; /* main canvas - neutral near-white */
|
||||
--surface-1: #fefefe; /* sidebar, panels */
|
||||
--surface-1: #f9f9f9; /* sidebar, panels */
|
||||
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
|
||||
--surface-3: #f7f7f7; /* popovers, headers */
|
||||
--surface-4: #f5f5f5; /* buttons base */
|
||||
--border: #e0e0e0; /* primary border */
|
||||
--surface-5: #f3f3f3; /* inputs, form elements */
|
||||
--border-1: #e0e0e0; /* stronger border */
|
||||
--surface-6: #f0f0f0; /* popovers, elevated surfaces */
|
||||
--surface-7: #ececec;
|
||||
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
|
||||
--surface-7: #d9d9d9;
|
||||
--surface-active: #eeeeee; /* hover/active state */
|
||||
|
||||
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
|
||||
|
||||
/* Text - neutral */
|
||||
--text-primary: #2d2d2d;
|
||||
--text-secondary: #404040;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #525252;
|
||||
--text-tertiary: #5c5c5c;
|
||||
--text-muted: #737373;
|
||||
--text-muted: #707070;
|
||||
--text-subtle: #8c8c8c;
|
||||
--text-inverse: #ffffff;
|
||||
--text-muted-inverse: #a0a0a0;
|
||||
@@ -125,7 +126,7 @@
|
||||
|
||||
/* Font weights - lighter for light mode */
|
||||
--font-weight-base: 430;
|
||||
--font-weight-medium: 450;
|
||||
--font-weight-medium: 440;
|
||||
--font-weight-semibold: 500;
|
||||
|
||||
/* Extended palette */
|
||||
@@ -211,7 +212,8 @@
|
||||
--surface-5: #363636;
|
||||
--border-1: #3d3d3d;
|
||||
--surface-6: #454545;
|
||||
--surface-7: #454545;
|
||||
--surface-7: #505050;
|
||||
--surface-active: #303030; /* hover/active state */
|
||||
|
||||
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
|
||||
|
||||
@@ -241,7 +243,7 @@
|
||||
--white: #ffffff;
|
||||
|
||||
/* Font weights - standard weights for dark mode */
|
||||
--font-weight-base: 440;
|
||||
--font-weight-base: 450;
|
||||
--font-weight-medium: 480;
|
||||
--font-weight-semibold: 550;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -5,17 +5,15 @@ import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { buildConversationHistory } from '@/lib/copilot/chat-context'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import {
|
||||
createSSEStream,
|
||||
requestChatTitle,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import {
|
||||
createStreamEventWriter,
|
||||
resetStreamBuffer,
|
||||
setStreamMeta,
|
||||
} from '@/lib/copilot/orchestrator/stream-buffer'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -23,54 +21,11 @@ import {
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
async function requestChatTitleFromCopilot(params: {
|
||||
message: string
|
||||
model: string
|
||||
provider?: string
|
||||
}): Promise<string | null> {
|
||||
const { message, model, provider } = params
|
||||
if (!message || !model) return null
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/api/generate-chat-title`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
model,
|
||||
...(provider ? { provider } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to generate chat title via copilot backend', {
|
||||
status: response.status,
|
||||
error: payload,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||
return title || null
|
||||
} catch (error) {
|
||||
logger.error('Error generating chat title:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
@@ -81,9 +36,10 @@ const FileAttachmentSchema = z.object({
|
||||
|
||||
const ChatMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
userMessageId: z.string().optional(), // ID from frontend for the user message
|
||||
userMessageId: z.string().optional(),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
model: z.string().optional().default('claude-opus-4-5'),
|
||||
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
|
||||
@@ -93,7 +49,6 @@ const ChatMessageSchema = z.object({
|
||||
implicitFeedback: z.string().optional(),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
provider: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
contexts: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -116,7 +71,6 @@ const ChatMessageSchema = z.object({
|
||||
blockIds: z.array(z.string()).optional(),
|
||||
templateId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
// For workflow_block, provide both workflowId and blockId
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
@@ -155,7 +109,6 @@ export async function POST(req: NextRequest) {
|
||||
implicitFeedback,
|
||||
fileAttachments,
|
||||
provider,
|
||||
conversationId,
|
||||
contexts,
|
||||
commands,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
@@ -174,7 +127,7 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
: contexts
|
||||
|
||||
// Resolve workflowId - if not provided, use first workflow or find by name
|
||||
// Copilot route always requires a workflow scope
|
||||
const resolved = await resolveWorkflowIdForUser(
|
||||
authenticatedUserId,
|
||||
providedWorkflowId,
|
||||
@@ -186,11 +139,22 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
const workflowId = resolved.workflowId
|
||||
const workflowResolvedName = resolved.workflowName
|
||||
|
||||
// Resolve workspace from workflow so it can be sent as implicit context to the Go backend.
|
||||
let resolvedWorkspaceId: string | undefined
|
||||
try {
|
||||
const { getWorkflowById } = await import('@/lib/workflows/utils')
|
||||
const wf = await getWorkflowById(workflowId)
|
||||
resolvedWorkspaceId = wf?.workspaceId ?? undefined
|
||||
} catch {
|
||||
logger.warn(`[${tracker.requestId}] Failed to resolve workspaceId from workflow`)
|
||||
}
|
||||
|
||||
// Ensure we have a consistent user message ID for this request
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] Received chat POST`, {
|
||||
workflowId,
|
||||
hasContexts: Array.isArray(normalizedContexts),
|
||||
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
|
||||
contextsPreview: Array.isArray(normalizedContexts)
|
||||
@@ -204,7 +168,7 @@ export async function POST(req: NextRequest) {
|
||||
: undefined,
|
||||
})
|
||||
} catch {}
|
||||
// Preprocess contexts server-side
|
||||
|
||||
let agentContexts: Array<{ type: string; content: string }> = []
|
||||
if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) {
|
||||
try {
|
||||
@@ -234,7 +198,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat context
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
@@ -249,27 +212,30 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
currentChat = chatResult.chat
|
||||
actualChatId = chatResult.chatId || chatId
|
||||
const history = buildConversationHistory(
|
||||
chatResult.conversationHistory,
|
||||
(chatResult.chat?.conversationId as string | undefined) || conversationId
|
||||
)
|
||||
conversationHistory = history.history
|
||||
conversationHistory = Array.isArray(chatResult.conversationHistory)
|
||||
? chatResult.conversationHistory
|
||||
: []
|
||||
}
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
const effectiveConversationId =
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
|
||||
const userPermission = resolvedWorkspaceId
|
||||
? await getUserEntityPermissions(authenticatedUserId, 'workspace', resolvedWorkspaceId).catch(
|
||||
() => null
|
||||
)
|
||||
: null
|
||||
|
||||
const requestPayload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message,
|
||||
workflowId,
|
||||
workflowId: workflowId || '',
|
||||
workflowName: workflowResolvedName,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
userId: authenticatedUserId,
|
||||
userMessageId: userMessageIdToUse,
|
||||
mode,
|
||||
model: selectedModel,
|
||||
provider,
|
||||
conversationId: effectiveConversationId,
|
||||
conversationHistory,
|
||||
contexts: agentContexts,
|
||||
fileAttachments,
|
||||
@@ -277,6 +243,7 @@ export async function POST(req: NextRequest) {
|
||||
chatId: actualChatId,
|
||||
prefetch,
|
||||
implicitFeedback,
|
||||
userPermission: userPermission ?? undefined,
|
||||
},
|
||||
{
|
||||
selectedModel,
|
||||
@@ -287,7 +254,6 @@ export async function POST(req: NextRequest) {
|
||||
logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
|
||||
hasContext: agentContexts.length > 0,
|
||||
contextCount: agentContexts.length,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
|
||||
messageLength: message.length,
|
||||
mode: effectiveMode,
|
||||
@@ -302,138 +268,35 @@ export async function POST(req: NextRequest) {
|
||||
} catch {}
|
||||
|
||||
if (stream) {
|
||||
const streamId = userMessageIdToUse
|
||||
let eventWriter: ReturnType<typeof createStreamEventWriter> | null = null
|
||||
let clientDisconnected = false
|
||||
const transformedStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
await resetStreamBuffer(streamId)
|
||||
await setStreamMeta(streamId, { status: 'active', userId: authenticatedUserId })
|
||||
eventWriter = createStreamEventWriter(streamId)
|
||||
|
||||
const shouldFlushEvent = (event: Record<string, any>) =>
|
||||
event.type === 'tool_call' ||
|
||||
event.type === 'tool_result' ||
|
||||
event.type === 'tool_error' ||
|
||||
event.type === 'subagent_end' ||
|
||||
event.type === 'structured_result' ||
|
||||
event.type === 'subagent_result' ||
|
||||
event.type === 'done' ||
|
||||
event.type === 'error'
|
||||
|
||||
const pushEvent = async (event: Record<string, any>) => {
|
||||
if (!eventWriter) return
|
||||
const entry = await eventWriter.write(event)
|
||||
if (shouldFlushEvent(event)) {
|
||||
await eventWriter.flush()
|
||||
}
|
||||
const payload = {
|
||||
...event,
|
||||
eventId: entry.eventId,
|
||||
streamId,
|
||||
}
|
||||
try {
|
||||
if (!clientDisconnected) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
|
||||
}
|
||||
} catch {
|
||||
clientDisconnected = true
|
||||
await eventWriter.flush()
|
||||
}
|
||||
}
|
||||
|
||||
if (actualChatId) {
|
||||
await pushEvent({ type: 'chat_id', chatId: actualChatId })
|
||||
}
|
||||
|
||||
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
|
||||
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
await pushEvent({ type: 'title_updated', title })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
onEvent: async (event) => {
|
||||
await pushEvent(event)
|
||||
},
|
||||
})
|
||||
|
||||
if (currentChat && result.conversationId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: result.conversationId,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
await eventWriter.close()
|
||||
await setStreamMeta(streamId, { status: 'complete', userId: authenticatedUserId })
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Orchestration error:`, error)
|
||||
await eventWriter.close()
|
||||
await setStreamMeta(streamId, {
|
||||
status: 'error',
|
||||
userId: authenticatedUserId,
|
||||
error: error instanceof Error ? error.message : 'Stream error',
|
||||
})
|
||||
await pushEvent({
|
||||
type: 'error',
|
||||
data: {
|
||||
displayMessage: 'An unexpected error occurred while processing the response.',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// controller may already be closed by cancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
clientDisconnected = true
|
||||
if (eventWriter) {
|
||||
await eventWriter.close().catch(() => {})
|
||||
}
|
||||
const sseStream = createSSEStream({
|
||||
requestPayload,
|
||||
userId: authenticatedUserId,
|
||||
streamId: userMessageIdToUse,
|
||||
chatId: actualChatId,
|
||||
currentChat,
|
||||
conversationHistory,
|
||||
message,
|
||||
titleModel: selectedModel,
|
||||
titleProvider: provider,
|
||||
requestId: tracker.requestId,
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(transformedStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
|
||||
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
})
|
||||
@@ -485,7 +348,7 @@ export async function POST(req: NextRequest) {
|
||||
// Start title generation in parallel if this is first message (non-streaming)
|
||||
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
|
||||
logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
|
||||
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
|
||||
requestChatTitle({ message, model: selectedModel, provider })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
@@ -509,9 +372,6 @@ export async function POST(req: NextRequest) {
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
...(nonStreamingResult.conversationId
|
||||
? { conversationId: nonStreamingResult.conversationId }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
@@ -563,16 +423,15 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const chatId = searchParams.get('chatId')
|
||||
|
||||
// Get authenticated user using consolidated helper
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !authenticatedUserId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// If chatId is provided, fetch a single chat
|
||||
if (chatId) {
|
||||
const [chat] = await db
|
||||
.select({
|
||||
@@ -582,6 +441,7 @@ export async function GET(req: NextRequest) {
|
||||
messages: copilotChats.messages,
|
||||
planArtifact: copilotChats.planArtifact,
|
||||
config: copilotChats.config,
|
||||
conversationId: copilotChats.conversationId,
|
||||
createdAt: copilotChats.createdAt,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
@@ -601,6 +461,7 @@ export async function GET(req: NextRequest) {
|
||||
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
|
||||
planArtifact: chat.planArtifact || null,
|
||||
config: chat.config || null,
|
||||
conversationId: chat.conversationId || null,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
}
|
||||
@@ -609,11 +470,14 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ success: true, chat: transformedChat })
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
return createBadRequestResponse('workflowId or chatId is required')
|
||||
if (!workflowId && !workspaceId) {
|
||||
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
|
||||
}
|
||||
|
||||
// Fetch chats for this user and workflow
|
||||
const scopeFilter = workflowId
|
||||
? eq(copilotChats.workflowId, workflowId)
|
||||
: eq(copilotChats.workspaceId, workspaceId!)
|
||||
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
@@ -626,12 +490,9 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(eq(copilotChats.userId, authenticatedUserId), eq(copilotChats.workflowId, workflowId))
|
||||
)
|
||||
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
|
||||
.orderBy(desc(copilotChats.updatedAt))
|
||||
|
||||
// Transform the data to include message count
|
||||
const transformedChats = chats.map((chat) => ({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
@@ -644,7 +505,8 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: chat.updatedAt,
|
||||
}))
|
||||
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for workflow ${workflowId}`)
|
||||
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for ${scope}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
document: {
|
||||
id: 'id',
|
||||
connectorId: 'connectorId',
|
||||
deletedAt: 'deletedAt',
|
||||
filename: 'filename',
|
||||
externalId: 'externalId',
|
||||
sourceUrl: 'sourceUrl',
|
||||
enabled: 'enabled',
|
||||
userExcluded: 'userExcluded',
|
||||
uploadedAt: 'uploadedAt',
|
||||
processingStatus: 'processingStatus',
|
||||
},
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: vi.fn(),
|
||||
checkKnowledgeBaseWriteAccess: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
describe('Connector Documents API Route', () => {
|
||||
/**
|
||||
* The route chains db calls in sequence. We track call order
|
||||
* to return different values for connector lookup vs document queries.
|
||||
*/
|
||||
let limitCallCount: number
|
||||
let orderByCallCount: number
|
||||
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn(() => {
|
||||
orderByCallCount++
|
||||
return Promise.resolve([])
|
||||
}),
|
||||
limit: vi.fn(() => {
|
||||
limitCallCount++
|
||||
return Promise.resolve([])
|
||||
}),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
limitCallCount = 0
|
||||
orderByCallCount = 0
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockImplementation(() => {
|
||||
orderByCallCount++
|
||||
return Promise.resolve([])
|
||||
})
|
||||
mockDbChain.limit.mockImplementation(() => {
|
||||
limitCallCount++
|
||||
return Promise.resolve([])
|
||||
})
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
mockDbChain.returning.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: false,
|
||||
userId: null,
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns documents list on success', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
|
||||
const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false }
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.orderBy.mockResolvedValueOnce([doc])
|
||||
|
||||
const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents'
|
||||
const req = createMockRequest('GET', undefined, undefined, url)
|
||||
Object.assign(req, { nextUrl: new URL(url) })
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.documents).toHaveLength(1)
|
||||
expect(data.data.counts.active).toBe(1)
|
||||
expect(data.data.counts.excluded).toBe(0)
|
||||
})
|
||||
|
||||
it('includes excluded documents when includeExcluded=true', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.orderBy
|
||||
.mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }])
|
||||
.mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }])
|
||||
|
||||
const url =
|
||||
'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true'
|
||||
const req = createMockRequest('GET', undefined, undefined, url)
|
||||
Object.assign(req, { nextUrl: new URL(url) })
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.documents).toHaveLength(2)
|
||||
expect(data.data.counts.active).toBe(1)
|
||||
expect(data.data.counts.excluded).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: false,
|
||||
userId: null,
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid body', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
|
||||
const req = createMockRequest('PATCH', { documentIds: [] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns success for restore operation', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.restoredCount).toBe(1)
|
||||
})
|
||||
|
||||
it('returns success for exclude operation', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
|
||||
|
||||
const req = createMockRequest('PATCH', {
|
||||
operation: 'exclude',
|
||||
documentIds: ['doc-2', 'doc-3'],
|
||||
})
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.excludedCount).toBe(2)
|
||||
expect(data.data.documentIds).toEqual(['doc-2', 'doc-3'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
import { db } from '@sim/db'
|
||||
import { document, knowledgeConnector } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('ConnectorDocumentsAPI')
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/[id]/connectors/[connectorId]/documents
|
||||
* Returns documents for a connector, optionally including user-excluded ones.
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectorRows = await db
|
||||
.select({ id: knowledgeConnector.id })
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (connectorRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const includeExcluded = request.nextUrl.searchParams.get('includeExcluded') === 'true'
|
||||
|
||||
const activeDocs = await db
|
||||
.select({
|
||||
id: document.id,
|
||||
filename: document.filename,
|
||||
externalId: document.externalId,
|
||||
sourceUrl: document.sourceUrl,
|
||||
enabled: document.enabled,
|
||||
userExcluded: document.userExcluded,
|
||||
uploadedAt: document.uploadedAt,
|
||||
processingStatus: document.processingStatus,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
isNull(document.deletedAt),
|
||||
eq(document.userExcluded, false)
|
||||
)
|
||||
)
|
||||
.orderBy(document.filename)
|
||||
|
||||
const excludedDocs = includeExcluded
|
||||
? await db
|
||||
.select({
|
||||
id: document.id,
|
||||
filename: document.filename,
|
||||
externalId: document.externalId,
|
||||
sourceUrl: document.sourceUrl,
|
||||
enabled: document.enabled,
|
||||
userExcluded: document.userExcluded,
|
||||
uploadedAt: document.uploadedAt,
|
||||
processingStatus: document.processingStatus,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
eq(document.userExcluded, true),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(document.filename)
|
||||
: []
|
||||
|
||||
const docs = [...activeDocs, ...excludedDocs]
|
||||
const activeCount = activeDocs.length
|
||||
const excludedCount = excludedDocs.length
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
documents: docs,
|
||||
counts: { active: activeCount, excluded: excludedCount },
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching connector documents`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const PatchSchema = z.object({
|
||||
operation: z.enum(['restore', 'exclude']),
|
||||
documentIds: z.array(z.string()).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /api/knowledge/[id]/connectors/[connectorId]/documents
|
||||
* Restore or exclude connector documents.
|
||||
*/
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectorRows = await db
|
||||
.select({ id: knowledgeConnector.id })
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (connectorRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = PatchSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { operation, documentIds } = parsed.data
|
||||
|
||||
if (operation === 'restore') {
|
||||
const updated = await db
|
||||
.update(document)
|
||||
.set({ userExcluded: false, deletedAt: null, enabled: true })
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
inArray(document.id, documentIds),
|
||||
eq(document.userExcluded, true)
|
||||
)
|
||||
)
|
||||
.returning({ id: document.id })
|
||||
|
||||
logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) },
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(document)
|
||||
.set({ userExcluded: true })
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
inArray(document.id, documentIds),
|
||||
eq(document.userExcluded, false),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: document.id })
|
||||
|
||||
logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating connector documents`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: vi.fn(),
|
||||
checkKnowledgeBaseWriteAccess: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
refreshAccessTokenIfNeeded: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/connectors/registry', () => ({
|
||||
CONNECTOR_REGISTRY: {
|
||||
jira: { validateConfig: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
knowledgeBase: { id: 'id', userId: 'userId' },
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
connectorType: 'connectorType',
|
||||
credentialId: 'credentialId',
|
||||
},
|
||||
knowledgeConnectorSyncLog: { connectorId: 'connectorId', startedAt: 'startedAt' },
|
||||
}))
|
||||
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
describe('Knowledge Connector By ID API Route', () => {
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockReturnThis()
|
||||
mockDbChain.limit.mockResolvedValue([])
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
|
||||
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when KB not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false, notFound: true })
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns connector with sync logs on success', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' }
|
||||
const mockLogs = [{ id: 'log-1', status: 'completed' }]
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs)
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.id).toBe('conn-456')
|
||||
expect(data.data.syncLogs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('PATCH', { status: 'paused' })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid body', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('Invalid request')
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found during sourceConfig validation', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 200 and updates status', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
|
||||
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
|
||||
|
||||
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.status).toBe('paused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await DELETE(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 200 on successful soft-delete', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await DELETE(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
document,
|
||||
knowledgeBase,
|
||||
knowledgeConnector,
|
||||
knowledgeConnectorSyncLog,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
|
||||
const logger = createLogger('KnowledgeConnectorByIdAPI')
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
|
||||
|
||||
const UpdateConnectorSchema = z.object({
|
||||
sourceConfig: z.record(z.unknown()).optional(),
|
||||
syncIntervalMinutes: z.number().int().min(0).optional(),
|
||||
status: z.enum(['active', 'paused']).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectorRows = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (connectorRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const syncLogs = await db
|
||||
.select()
|
||||
.from(knowledgeConnectorSyncLog)
|
||||
.where(eq(knowledgeConnectorSyncLog.connectorId, connectorId))
|
||||
.orderBy(desc(knowledgeConnectorSyncLog.startedAt))
|
||||
.limit(10)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...connectorRows[0],
|
||||
syncLogs,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
|
||||
*/
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = UpdateConnectorSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (parsed.data.sourceConfig !== undefined) {
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const existing = existingRows[0]
|
||||
const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]
|
||||
|
||||
if (!connectorConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown connector type: ${existing.connectorType}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const kbRows = await db
|
||||
.select({ userId: knowledgeBase.userId })
|
||||
.from(knowledgeBase)
|
||||
.where(eq(knowledgeBase.id, knowledgeBaseId))
|
||||
.limit(1)
|
||||
|
||||
if (kbRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
existing.credentialId,
|
||||
kbRows[0].userId,
|
||||
`patch-${connectorId}`
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to refresh access token. Please reconnect your account.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const validation = await connectorConfig.validateConfig(accessToken, parsed.data.sourceConfig)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: validation.error || 'Invalid source configuration' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (parsed.data.sourceConfig !== undefined) {
|
||||
updates.sourceConfig = parsed.data.sourceConfig
|
||||
}
|
||||
if (parsed.data.syncIntervalMinutes !== undefined) {
|
||||
updates.syncIntervalMinutes = parsed.data.syncIntervalMinutes
|
||||
if (parsed.data.syncIntervalMinutes > 0) {
|
||||
updates.nextSyncAt = new Date(Date.now() + parsed.data.syncIntervalMinutes * 60 * 1000)
|
||||
} else {
|
||||
updates.nextSyncAt = null
|
||||
}
|
||||
}
|
||||
if (parsed.data.status !== undefined) {
|
||||
updates.status = parsed.data.status
|
||||
}
|
||||
|
||||
await db
|
||||
.update(knowledgeConnector)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ success: true, data: updated[0] })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/knowledge/[id]/connectors/[connectorId] - Soft-delete a connector
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId, connectorId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
await db
|
||||
.update(knowledgeConnector)
|
||||
.set({ deletedAt: new Date(), status: 'paused', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.id, connectorId),
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
// Soft-delete all documents belonging to this connector
|
||||
await db
|
||||
.update(document)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(eq(document.connectorId, connectorId), isNull(document.deletedAt)))
|
||||
|
||||
// Reclaim tag slots that are no longer used by any active connector
|
||||
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Soft-deleted connector ${connectorId} and its documents`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
status: 'status',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseWriteAccess: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
|
||||
dispatchSync: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
describe('Connector Manual Sync API Route', () => {
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockResolvedValue([]),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockResolvedValue([])
|
||||
mockDbChain.limit.mockResolvedValue([])
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
|
||||
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: false,
|
||||
userId: null,
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when connector is syncing', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
})
|
||||
|
||||
it('dispatches sync on valid request', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
const { dispatchSync } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(vi.mocked(dispatchSync)).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
204
apps/sim/app/api/knowledge/[id]/connectors/route.ts
Normal file
204
apps/sim/app/api/knowledge/[id]/connectors/route.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { db } from '@sim/db'
|
||||
import { knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
import { allocateTagSlots } from '@/lib/knowledge/constants'
|
||||
import { createTagDefinition } from '@/lib/knowledge/tags/service'
|
||||
import { getCredential } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
|
||||
const logger = createLogger('KnowledgeConnectorsAPI')
|
||||
|
||||
const CreateConnectorSchema = z.object({
|
||||
connectorType: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
sourceConfig: z.record(z.unknown()),
|
||||
syncIntervalMinutes: z.number().int().min(0).default(1440),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/[id]/connectors - List connectors for a knowledge base
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const connectors = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(knowledgeConnector.createdAt))
|
||||
|
||||
return NextResponse.json({ success: true, data: connectors })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing connectors`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/knowledge/[id]/connectors - Create a new connector
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
||||
if (!writeCheck.hasAccess) {
|
||||
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
||||
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = CreateConnectorSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { connectorType, credentialId, sourceConfig, syncIntervalMinutes } = parsed.data
|
||||
|
||||
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
|
||||
if (!connectorConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown connector type: ${connectorType}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, auth.userId)
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!credential.accessToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential has no access token. Please reconnect your account.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const validation = await connectorConfig.validateConfig(credential.accessToken, sourceConfig)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: validation.error || 'Invalid source configuration' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let finalSourceConfig: Record<string, unknown> = sourceConfig
|
||||
const tagSlotMapping: Record<string, string> = {}
|
||||
|
||||
if (connectorConfig.tagDefinitions?.length) {
|
||||
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
|
||||
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
|
||||
|
||||
const existingDefs = await db
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
|
||||
Object.assign(tagSlotMapping, mapping)
|
||||
|
||||
for (const name of skippedTags) {
|
||||
logger.warn(`[${requestId}] No available slots for "${name}"`)
|
||||
}
|
||||
|
||||
if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
finalSourceConfig = { ...sourceConfig, tagSlotMapping }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const connectorId = crypto.randomUUID()
|
||||
const nextSyncAt =
|
||||
syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const [semanticId, slot] of Object.entries(tagSlotMapping)) {
|
||||
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
|
||||
await createTagDefinition(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
tagSlot: slot,
|
||||
displayName: td.displayName,
|
||||
fieldType: td.fieldType,
|
||||
},
|
||||
requestId,
|
||||
tx
|
||||
)
|
||||
}
|
||||
|
||||
await tx.insert(knowledgeConnector).values({
|
||||
id: connectorId,
|
||||
knowledgeBaseId,
|
||||
connectorType,
|
||||
credentialId,
|
||||
sourceConfig: finalSourceConfig,
|
||||
syncIntervalMinutes,
|
||||
status: 'active',
|
||||
nextSyncAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
|
||||
|
||||
dispatchSync(connectorId, { requestId }).catch((error) => {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
|
||||
error
|
||||
)
|
||||
})
|
||||
|
||||
const created = await db
|
||||
.select()
|
||||
.from(knowledgeConnector)
|
||||
.where(eq(knowledgeConnector.id, connectorId))
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ success: true, data: created[0] }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating connector`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getDocuments,
|
||||
getProcessingConfig,
|
||||
processDocumentsWithQueue,
|
||||
type TagFilterCondition,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
@@ -131,6 +132,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
? (sortOrderParam as SortOrder)
|
||||
: undefined
|
||||
|
||||
let tagFilters: TagFilterCondition[] | undefined
|
||||
const tagFiltersParam = url.searchParams.get('tagFilters')
|
||||
if (tagFiltersParam) {
|
||||
try {
|
||||
const parsed = JSON.parse(tagFiltersParam)
|
||||
if (Array.isArray(parsed)) {
|
||||
tagFilters = parsed.filter(
|
||||
(f: TagFilterCondition) => f.tagSlot && f.operator && f.value !== undefined
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
logger.warn(`[${requestId}] Invalid tagFilters param`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getDocuments(
|
||||
knowledgeBaseId,
|
||||
{
|
||||
@@ -140,6 +156,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
offset,
|
||||
...(sortBy && { sortBy }),
|
||||
...(sortOrder && { sortOrder }),
|
||||
tagFilters,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
68
apps/sim/app/api/knowledge/connectors/sync/route.ts
Normal file
68
apps/sim/app/api/knowledge/connectors/sync/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { db } from '@sim/db'
|
||||
import { knowledgeConnector } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, inArray, isNull, lte } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('ConnectorSyncSchedulerAPI')
|
||||
|
||||
/**
|
||||
* Cron endpoint that checks for connectors due for sync and dispatches sync jobs.
|
||||
* Should be called every 5 minutes by an external cron service.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
logger.info(`[${requestId}] Connector sync scheduler triggered`)
|
||||
|
||||
const authError = verifyCronAuth(request, 'Connector sync scheduler')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
const dueConnectors = await db
|
||||
.select({
|
||||
id: knowledgeConnector.id,
|
||||
})
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
inArray(knowledgeConnector.status, ['active', 'error']),
|
||||
lte(knowledgeConnector.nextSyncAt, now),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for sync`)
|
||||
|
||||
if (dueConnectors.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No connectors due for sync',
|
||||
count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for (const connector of dueConnectors) {
|
||||
dispatchSync(connector.id, { requestId }).catch((error) => {
|
||||
logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error)
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Dispatched ${dueConnectors.length} connector sync(s)`,
|
||||
count: dueConnectors.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Connector sync scheduler error`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import {
|
||||
ORCHESTRATION_TIMEOUT_MS,
|
||||
SIM_AGENT_API_URL,
|
||||
SIM_AGENT_VERSION,
|
||||
} from '@/lib/copilot/constants'
|
||||
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
|
||||
import {
|
||||
@@ -724,16 +720,14 @@ async function handleBuildToolCall(
|
||||
mode: 'agent',
|
||||
commands: ['fast'],
|
||||
messageId: randomUUID(),
|
||||
version: SIM_AGENT_VERSION,
|
||||
headless: true,
|
||||
chatId,
|
||||
source: 'mcp',
|
||||
}
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: true,
|
||||
timeout: 300000,
|
||||
interactive: false,
|
||||
|
||||
229
apps/sim/app/api/mothership/chat/route.ts
Normal file
229
apps/sim/app/api/mothership/chat/route.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
|
||||
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
|
||||
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('MothershipChatAPI')
|
||||
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
|
||||
const MothershipMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
workspaceId: z.string().min(1, 'workspaceId is required'),
|
||||
userMessageId: z.string().optional(),
|
||||
chatId: z.string().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
contexts: z
|
||||
.array(
|
||||
z.object({
|
||||
kind: z.enum([
|
||||
'past_chat',
|
||||
'workflow',
|
||||
'current_workflow',
|
||||
'blocks',
|
||||
'logs',
|
||||
'workflow_block',
|
||||
'knowledge',
|
||||
'templates',
|
||||
'docs',
|
||||
]),
|
||||
label: z.string(),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
knowledgeId: z.string().optional(),
|
||||
blockId: z.string().optional(),
|
||||
blockIds: z.array(z.string()).optional(),
|
||||
templateId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/mothership/chat
|
||||
* Workspace-scoped chat — no workflowId, proxies to Go /api/mothership.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const authenticatedUserId = session.user.id
|
||||
const body = await req.json()
|
||||
const {
|
||||
message,
|
||||
workspaceId,
|
||||
userMessageId: providedMessageId,
|
||||
chatId,
|
||||
createNewChat,
|
||||
fileAttachments,
|
||||
contexts,
|
||||
} = MothershipMessageSchema.parse(body)
|
||||
|
||||
const userMessageId = providedMessageId || crypto.randomUUID()
|
||||
|
||||
let agentContexts: Array<{ type: string; content: string }> = []
|
||||
if (Array.isArray(contexts) && contexts.length > 0) {
|
||||
try {
|
||||
const { processContextsServer } = await import('@/lib/copilot/process-contents')
|
||||
agentContexts = await processContextsServer(contexts as any, authenticatedUserId, message)
|
||||
} catch (e) {
|
||||
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
|
||||
}
|
||||
}
|
||||
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
|
||||
if (chatId || createNewChat) {
|
||||
const chatResult = await resolveOrCreateChat({
|
||||
chatId,
|
||||
userId: authenticatedUserId,
|
||||
workspaceId,
|
||||
model: 'claude-opus-4-5',
|
||||
type: 'mothership',
|
||||
})
|
||||
currentChat = chatResult.chat
|
||||
actualChatId = chatResult.chatId || chatId
|
||||
conversationHistory = Array.isArray(chatResult.conversationHistory)
|
||||
? chatResult.conversationHistory
|
||||
: []
|
||||
}
|
||||
|
||||
if (actualChatId) {
|
||||
const userMsg = {
|
||||
id: userMessageId,
|
||||
role: 'user' as const,
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: [...conversationHistory, userMsg],
|
||||
conversationId: userMessageId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId))
|
||||
}
|
||||
|
||||
const [workspaceContext, userPermission] = await Promise.all([
|
||||
generateWorkspaceContext(workspaceId, authenticatedUserId),
|
||||
getUserEntityPermissions(authenticatedUserId, 'workspace', workspaceId).catch(() => null),
|
||||
])
|
||||
|
||||
const requestPayload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message,
|
||||
workspaceId,
|
||||
userId: authenticatedUserId,
|
||||
userMessageId,
|
||||
mode: 'agent',
|
||||
model: '',
|
||||
conversationHistory,
|
||||
contexts: agentContexts,
|
||||
fileAttachments,
|
||||
chatId: actualChatId,
|
||||
userPermission: userPermission ?? undefined,
|
||||
workspaceContext,
|
||||
},
|
||||
{ selectedModel: '' }
|
||||
)
|
||||
|
||||
const stream = createSSEStream({
|
||||
requestPayload,
|
||||
userId: authenticatedUserId,
|
||||
streamId: userMessageId,
|
||||
chatId: actualChatId,
|
||||
currentChat,
|
||||
conversationHistory,
|
||||
message,
|
||||
titleModel: 'claude-opus-4-5',
|
||||
requestId: tracker.requestId,
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workspaceId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/mothership',
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
onComplete: async (result: OrchestratorResult) => {
|
||||
if (!actualChatId) return
|
||||
|
||||
const userMessage = {
|
||||
id: userMessageId,
|
||||
role: 'user' as const,
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant' as const,
|
||||
content: result.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
conversationId: null,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId))
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
|
||||
chatId: actualChatId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, { headers: SSE_RESPONSE_HEADERS })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${tracker.requestId}] Error handling mothership chat:`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
93
apps/sim/app/api/mothership/chats/route.ts
Normal file
93
apps/sim/app/api/mothership/chats/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
|
||||
const logger = createLogger('MothershipChatsAPI')
|
||||
|
||||
/**
|
||||
* GET /api/mothership/chats?workspaceId=xxx
|
||||
* Returns mothership (home) chats for the authenticated user in the given workspace.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
|
||||
if (!workspaceId) {
|
||||
return createBadRequestResponse('workspaceId is required')
|
||||
}
|
||||
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
title: copilotChats.title,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.userId, userId),
|
||||
eq(copilotChats.workspaceId, workspaceId),
|
||||
eq(copilotChats.type, 'mothership')
|
||||
)
|
||||
)
|
||||
.orderBy(desc(copilotChats.updatedAt))
|
||||
|
||||
return NextResponse.json({ success: true, data: chats })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching mothership chats:', error)
|
||||
return createInternalServerErrorResponse('Failed to fetch chats')
|
||||
}
|
||||
}
|
||||
|
||||
const CreateChatSchema = z.object({
|
||||
workspaceId: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/mothership/chats
|
||||
* Creates an empty mothership chat and returns its ID.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { workspaceId } = CreateChatSchema.parse(body)
|
||||
|
||||
const [chat] = await db
|
||||
.insert(copilotChats)
|
||||
.values({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: 'mothership',
|
||||
title: null,
|
||||
model: 'claude-opus-4-5',
|
||||
messages: [],
|
||||
})
|
||||
.returning({ id: copilotChats.id })
|
||||
|
||||
return NextResponse.json({ success: true, id: chat.id })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createBadRequestResponse('workspaceId is required')
|
||||
}
|
||||
logger.error('Error creating mothership chat:', error)
|
||||
return createInternalServerErrorResponse('Failed to create chat')
|
||||
}
|
||||
}
|
||||
109
apps/sim/app/api/mothership/execute/route.ts
Normal file
109
apps/sim/app/api/mothership/execute/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('MothershipExecuteAPI')
|
||||
|
||||
const MessageSchema = z.object({
|
||||
role: z.enum(['system', 'user', 'assistant']),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
const ExecuteRequestSchema = z.object({
|
||||
messages: z.array(MessageSchema).min(1, 'At least one message is required'),
|
||||
responseFormat: z.any().optional(),
|
||||
workspaceId: z.string().min(1, 'workspaceId is required'),
|
||||
userId: z.string().min(1, 'userId is required'),
|
||||
chatId: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/mothership/execute
|
||||
*
|
||||
* Non-streaming endpoint for Mothership block execution within workflows.
|
||||
* Called by the executor via internal JWT auth, not by the browser directly.
|
||||
* Consumes the Go SSE stream internally and returns a single JSON response.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { messages, responseFormat, workspaceId, userId, chatId } =
|
||||
ExecuteRequestSchema.parse(body)
|
||||
|
||||
const effectiveChatId = chatId || crypto.randomUUID()
|
||||
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
|
||||
generateWorkspaceContext(workspaceId, userId),
|
||||
buildIntegrationToolSchemas(),
|
||||
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
|
||||
])
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
messages,
|
||||
responseFormat,
|
||||
userId,
|
||||
chatId: effectiveChatId,
|
||||
mode: 'agent',
|
||||
messageId: crypto.randomUUID(),
|
||||
isHosted: true,
|
||||
workspaceContext,
|
||||
...(integrationTools.length > 0 ? { integrationTools } : {}),
|
||||
...(userPermission ? { userPermission } : {}),
|
||||
}
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workspaceId,
|
||||
chatId: effectiveChatId,
|
||||
goRoute: '/api/mothership/execute',
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Mothership execute failed', {
|
||||
error: result.error,
|
||||
errors: result.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error || 'Mothership execution failed',
|
||||
content: result.content || '',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
content: result.content,
|
||||
model: 'mothership',
|
||||
tokens: {},
|
||||
toolCalls: result.toolCalls,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Mothership execute error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { skill } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { upsertSkills } from '@/lib/workflows/skills/operations'
|
||||
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('SkillsAPI')
|
||||
@@ -53,11 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(skill)
|
||||
.where(eq(skill.workspaceId, workspaceId))
|
||||
.orderBy(desc(skill.createdAt))
|
||||
const result = await listSkills({ workspaceId })
|
||||
|
||||
return NextResponse.json({ data: result }, { status: 200 })
|
||||
} catch (error) {
|
||||
@@ -159,20 +152,12 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const existingSkill = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1)
|
||||
|
||||
if (existingSkill.length === 0) {
|
||||
const deleted = await deleteSkill({ skillId, workspaceId })
|
||||
if (!deleted) {
|
||||
logger.warn(`[${requestId}] Skill not found: ${skillId}`)
|
||||
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existingSkill[0].workspaceId !== workspaceId) {
|
||||
logger.warn(`[${requestId}] Skill ${skillId} does not belong to workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.delete(skill).where(and(eq(skill.id, skillId), eq(skill.workspaceId, workspaceId)))
|
||||
|
||||
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteTable, type TableSchema } from '@/lib/table'
|
||||
import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils'
|
||||
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
|
||||
|
||||
const logger = createLogger('TableDetailAPI')
|
||||
|
||||
@@ -38,11 +38,7 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -108,11 +104,7 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import { validateRowData } from '@/lib/table'
|
||||
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
|
||||
import type { RowData } from '@/lib/table'
|
||||
import { updateRow } from '@/lib/table'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
|
||||
const logger = createLogger('TableRowAPI')
|
||||
|
||||
@@ -50,11 +50,7 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -87,8 +83,10 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
createdAt:
|
||||
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
||||
updatedAt:
|
||||
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -116,7 +114,13 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = UpdateRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
@@ -124,15 +128,10 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing row to support partial updates
|
||||
const [existingRow] = await db
|
||||
.select({ data: userTableRows.data })
|
||||
.from(userTableRows)
|
||||
@@ -149,42 +148,21 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Merge existing data with incoming partial data (incoming takes precedence)
|
||||
const mergedData = {
|
||||
...(existingRow.data as RowData),
|
||||
...(validated.data as RowData),
|
||||
}
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData: mergedData,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
excludeRowId: rowId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const [updatedRow] = await db
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
const updatedRow = await updateRow(
|
||||
{
|
||||
tableId,
|
||||
rowId,
|
||||
data: mergedData,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updatedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Updated row ${rowId} in table ${tableId}`)
|
||||
workspaceId: validated.workspaceId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -192,8 +170,14 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
row: {
|
||||
id: updatedRow.id,
|
||||
data: updatedRow.data,
|
||||
createdAt: updatedRow.createdAt.toISOString(),
|
||||
updatedAt: updatedRow.updatedAt.toISOString(),
|
||||
createdAt:
|
||||
updatedRow.createdAt instanceof Date
|
||||
? updatedRow.createdAt.toISOString()
|
||||
: updatedRow.createdAt,
|
||||
updatedAt:
|
||||
updatedRow.updatedAt instanceof Date
|
||||
? updatedRow.updatedAt.toISOString()
|
||||
: updatedRow.updatedAt,
|
||||
},
|
||||
message: 'Row updated successfully',
|
||||
},
|
||||
@@ -206,6 +190,22 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (errorMessage === 'Row not found') {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 404 })
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes('Row size exceeds') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('Cannot set unique column')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
|
||||
}
|
||||
@@ -222,7 +222,13 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = DeleteRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
@@ -230,11 +236,7 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,19 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
checkUniqueConstraintsDb,
|
||||
getUniqueColumns,
|
||||
batchInsertRows,
|
||||
deleteRowsByFilter,
|
||||
deleteRowsByIds,
|
||||
insertRow,
|
||||
TABLE_LIMITS,
|
||||
USER_TABLE_ROWS_SQL_NAME,
|
||||
updateRowsByFilter,
|
||||
validateBatchRows,
|
||||
validateRowAgainstSchema,
|
||||
validateRowData,
|
||||
validateRowSize,
|
||||
} from '@/lib/table'
|
||||
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
|
||||
import { accessError, checkAccess } from '../../utils'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
|
||||
const logger = createLogger('TableRowsAPI')
|
||||
|
||||
@@ -54,27 +56,32 @@ const QueryRowsSchema = z.object({
|
||||
.default(0),
|
||||
})
|
||||
|
||||
const nonEmptyFilter = z
|
||||
.record(z.unknown(), { required_error: 'Filter criteria is required' })
|
||||
.refine((f) => Object.keys(f).length > 0, { message: 'Filter must not be empty' })
|
||||
|
||||
const optionalPositiveLimit = (max: number, label: string) =>
|
||||
z.preprocess(
|
||||
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
|
||||
z
|
||||
.number()
|
||||
.int(`${label} must be an integer`)
|
||||
.min(1, `${label} must be at least 1`)
|
||||
.max(max, `Cannot ${label.toLowerCase()} more than ${max} rows per operation`)
|
||||
.optional()
|
||||
)
|
||||
|
||||
const UpdateRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
|
||||
filter: nonEmptyFilter,
|
||||
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(1000, 'Cannot update more than 1000 rows per operation')
|
||||
.optional(),
|
||||
limit: optionalPositiveLimit(1000, 'Limit'),
|
||||
})
|
||||
|
||||
const DeleteRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(1000, 'Cannot delete more than 1000 rows per operation')
|
||||
.optional(),
|
||||
filter: nonEmptyFilter,
|
||||
limit: optionalPositiveLimit(1000, 'Limit'),
|
||||
})
|
||||
|
||||
const DeleteRowsByIdsSchema = z.object({
|
||||
@@ -111,18 +118,8 @@ async function handleBatchInsert(
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceId = validated.workspaceId
|
||||
|
||||
const remainingCapacity = table.maxRows - table.rowCount
|
||||
if (remainingCapacity < validated.rows.length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${table.rowCount}/${table.maxRows} rows)`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate rows before calling service (service also validates, but route-level
|
||||
// validation returns structured HTTP responses)
|
||||
const validation = await validateBatchRows({
|
||||
rows: validated.rows as RowData[],
|
||||
schema: table.schema as TableSchema,
|
||||
@@ -130,34 +127,48 @@ async function handleBatchInsert(
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const now = new Date()
|
||||
const rowsToInsert = validated.rows.map((data) => ({
|
||||
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
tableId,
|
||||
workspaceId,
|
||||
data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: userId,
|
||||
}))
|
||||
try {
|
||||
const insertedRows = await batchInsertRows(
|
||||
{
|
||||
tableId,
|
||||
rows: validated.rows as RowData[],
|
||||
workspaceId: validated.workspaceId,
|
||||
userId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
const insertedRows = await db.insert(userTableRows).values(rowsToInsert).returning()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: insertedRows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
|
||||
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
|
||||
})),
|
||||
insertedCount: insertedRows.length,
|
||||
message: `Successfully inserted ${insertedRows.length} rows`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
logger.info(`[${requestId}] Batch inserted ${insertedRows.length} rows into table ${tableId}`)
|
||||
if (
|
||||
errorMessage.includes('row limit') ||
|
||||
errorMessage.includes('Insufficient capacity') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Row size exceeds') ||
|
||||
errorMessage.match(/^Row \d+:/)
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: insertedRows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
insertedCount: insertedRows.length,
|
||||
message: `Successfully inserted ${insertedRows.length} rows`,
|
||||
},
|
||||
})
|
||||
logger.error(`[${requestId}] Error batch inserting rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */
|
||||
@@ -171,7 +182,12 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
@@ -201,9 +217,9 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceId = validated.workspaceId
|
||||
const rowData = validated.data as RowData
|
||||
|
||||
// Validate at route level for structured HTTP error responses
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema: table.schema as TableSchema,
|
||||
@@ -211,30 +227,17 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
if (table.rowCount >= table.maxRows) {
|
||||
return NextResponse.json(
|
||||
{ error: `Table row limit reached (${table.maxRows} rows max)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const rowId = `row_${crypto.randomUUID().replace(/-/g, '')}`
|
||||
const now = new Date()
|
||||
|
||||
const [row] = await db
|
||||
.insert(userTableRows)
|
||||
.values({
|
||||
id: rowId,
|
||||
// Service handles atomic capacity check + insert in a transaction
|
||||
const row = await insertRow(
|
||||
{
|
||||
tableId,
|
||||
workspaceId,
|
||||
data: validated.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: authResult.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Inserted row ${rowId} into table ${tableId}`)
|
||||
data: rowData,
|
||||
workspaceId: validated.workspaceId,
|
||||
userId: authResult.userId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -242,8 +245,8 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
|
||||
},
|
||||
message: 'Row inserted successfully',
|
||||
},
|
||||
@@ -256,6 +259,18 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (
|
||||
errorMessage.includes('row limit') ||
|
||||
errorMessage.includes('Insufficient capacity') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Row size exceeds')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error inserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
|
||||
}
|
||||
@@ -364,8 +379,8 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
|
||||
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
totalCount: Number(totalCount),
|
||||
@@ -397,7 +412,13 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = UpdateRowsByFilterSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
@@ -412,9 +433,7 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updateData = validated.data as RowData
|
||||
|
||||
const sizeValidation = validateRowSize(updateData)
|
||||
const sizeValidation = validateRowSize(validated.data as RowData)
|
||||
if (!sizeValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid row data', details: sizeValidation.errors },
|
||||
@@ -422,31 +441,19 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
|
||||
)
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
const result = await updateRowsByFilter(
|
||||
{
|
||||
tableId,
|
||||
filter: validated.filter as Filter,
|
||||
data: validated.data as RowData,
|
||||
limit: validated.limit,
|
||||
workspaceId: validated.workspaceId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
|
||||
let matchingRowsQuery = db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.limit) {
|
||||
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
|
||||
}
|
||||
|
||||
const matchingRows = await matchingRowsQuery
|
||||
|
||||
if (matchingRows.length === 0) {
|
||||
if (result.affectedCount === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
@@ -459,103 +466,12 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
|
||||
)
|
||||
}
|
||||
|
||||
if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
|
||||
logger.warn(`[${requestId}] Updating ${matchingRows.length} rows. This may take some time.`)
|
||||
}
|
||||
|
||||
for (const row of matchingRows) {
|
||||
const existingData = row.data as RowData
|
||||
const mergedData = { ...existingData, ...updateData }
|
||||
const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema)
|
||||
if (!rowValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Updated data does not match schema',
|
||||
details: rowValidation.errors,
|
||||
affectedRowId: row.id,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueColumns = getUniqueColumns(table.schema as TableSchema)
|
||||
if (uniqueColumns.length > 0) {
|
||||
// If updating multiple rows, check that updateData doesn't set any unique column
|
||||
// (would cause all rows to have the same value, violating uniqueness)
|
||||
if (matchingRows.length > 1) {
|
||||
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in updateData)
|
||||
if (uniqueColumnsInUpdate.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot set unique column values when updating multiple rows',
|
||||
details: [
|
||||
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
|
||||
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`,
|
||||
],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check unique constraints against database for each row
|
||||
for (const row of matchingRows) {
|
||||
const existingData = row.data as RowData
|
||||
const mergedData = { ...existingData, ...updateData }
|
||||
const uniqueValidation = await checkUniqueConstraintsDb(
|
||||
tableId,
|
||||
mergedData,
|
||||
table.schema as TableSchema,
|
||||
row.id
|
||||
)
|
||||
|
||||
if (!uniqueValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unique constraint violation',
|
||||
details: uniqueValidation.errors,
|
||||
affectedRowId: row.id,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
let totalUpdated = 0
|
||||
|
||||
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
|
||||
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
|
||||
const updatePromises = batch.map((row) => {
|
||||
const existingData = row.data as RowData
|
||||
return trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: { ...existingData, ...updateData },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, row.id))
|
||||
})
|
||||
await Promise.all(updatePromises)
|
||||
totalUpdated += batch.length
|
||||
logger.info(
|
||||
`[${requestId}] Updated batch ${Math.floor(i / TABLE_LIMITS.UPDATE_BATCH_SIZE) + 1} (${totalUpdated}/${matchingRows.length} rows)`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows updated successfully',
|
||||
updatedCount: matchingRows.length,
|
||||
updatedRowIds: matchingRows.map((r) => r.id),
|
||||
updatedCount: result.affectedCount,
|
||||
updatedRowIds: result.affectedRowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -566,16 +482,25 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating rows by filter:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to update rows: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
if (
|
||||
errorMessage.includes('Row size exceeds') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('Cannot set unique column') ||
|
||||
errorMessage.includes('Filter is required')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating rows by filter:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria. */
|
||||
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria or by IDs. */
|
||||
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
@@ -586,7 +511,13 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = DeleteRowsRequestSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
@@ -601,110 +532,46 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
let rowIds: string[] = []
|
||||
let missingRowIds: string[] | undefined
|
||||
let requestedCount: number | undefined
|
||||
|
||||
if ('rowIds' in validated) {
|
||||
const uniqueRequestedRowIds = Array.from(new Set(validated.rowIds))
|
||||
requestedCount = uniqueRequestedRowIds.length
|
||||
|
||||
const matchingRows = await db
|
||||
.select({ id: userTableRows.id })
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
...baseConditions,
|
||||
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
|
||||
uniqueRequestedRowIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)}])`
|
||||
)
|
||||
)
|
||||
|
||||
const matchedRowIds = matchingRows.map((r) => r.id)
|
||||
const matchedIdSet = new Set(matchedRowIds)
|
||||
missingRowIds = uniqueRequestedRowIds.filter((id) => !matchedIdSet.has(id))
|
||||
rowIds = matchedRowIds
|
||||
} else {
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
|
||||
let matchingRowsQuery = db
|
||||
.select({ id: userTableRows.id })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.limit) {
|
||||
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
|
||||
}
|
||||
|
||||
const matchingRows = await matchingRowsQuery
|
||||
rowIds = matchingRows.map((r) => r.id)
|
||||
}
|
||||
|
||||
if (rowIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
message:
|
||||
'rowIds' in validated
|
||||
? 'No matching rows found for the provided IDs'
|
||||
: 'No rows matched the filter criteria',
|
||||
deletedCount: 0,
|
||||
deletedRowIds: [],
|
||||
...(requestedCount !== undefined ? { requestedCount } : {}),
|
||||
...(missingRowIds ? { missingRowIds } : {}),
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
const result = await deleteRowsByIds(
|
||||
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message:
|
||||
result.deletedCount === 0
|
||||
? 'No matching rows found for the provided IDs'
|
||||
: 'Rows deleted successfully',
|
||||
deletedCount: result.deletedCount,
|
||||
deletedRowIds: result.deletedRowIds,
|
||||
requestedCount: result.requestedCount,
|
||||
...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (rowIds.length > TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
logger.warn(`[${requestId}] Deleting ${rowIds.length} rows. This may take some time.`)
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
let totalDeleted = 0
|
||||
|
||||
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
|
||||
await trx.delete(userTableRows).where(
|
||||
and(
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
|
||||
batch.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)}])`
|
||||
)
|
||||
)
|
||||
totalDeleted += batch.length
|
||||
logger.info(
|
||||
`[${requestId}] Deleted batch ${Math.floor(i / TABLE_LIMITS.DELETE_BATCH_SIZE) + 1} (${totalDeleted}/${rowIds.length} rows)`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Deleted ${rowIds.length} rows from table ${tableId}`)
|
||||
const result = await deleteRowsByFilter(
|
||||
{
|
||||
tableId,
|
||||
filter: validated.filter as Filter,
|
||||
limit: validated.limit,
|
||||
workspaceId: validated.workspaceId,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows deleted successfully',
|
||||
deletedCount: rowIds.length,
|
||||
deletedRowIds: rowIds,
|
||||
...(requestedCount !== undefined ? { requestedCount } : {}),
|
||||
...(missingRowIds ? { missingRowIds } : {}),
|
||||
message:
|
||||
result.affectedCount === 0
|
||||
? 'No rows matched the filter criteria'
|
||||
: 'Rows deleted successfully',
|
||||
deletedCount: result.affectedCount,
|
||||
deletedRowIds: result.affectedRowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -715,11 +582,13 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting rows by filter:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to delete rows: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
if (errorMessage.includes('Filter is required')) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import { getUniqueColumns, validateRowData } from '@/lib/table'
|
||||
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
|
||||
import type { RowData } from '@/lib/table'
|
||||
import { upsertRow } from '@/lib/table'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
|
||||
const logger = createLogger('TableUpsertAPI')
|
||||
|
||||
const UpsertRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
conflictTarget: z.string().optional(),
|
||||
})
|
||||
|
||||
interface UpsertRouteParams {
|
||||
@@ -32,7 +30,13 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = UpsertRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
@@ -40,115 +44,20 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const schema = table.schema as TableSchema
|
||||
const rowData = validated.data as RowData
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema,
|
||||
tableId,
|
||||
checkUnique: false,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const uniqueColumns = getUniqueColumns(schema)
|
||||
|
||||
if (uniqueColumns.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const uniqueFilters = uniqueColumns.map((col) => {
|
||||
const value = rowData[col.name]
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
return sql`${userTableRows.data}->>${col.name} = ${String(value)}`
|
||||
})
|
||||
|
||||
const validUniqueFilters = uniqueFilters.filter((f): f is Exclude<typeof f, null> => f !== null)
|
||||
|
||||
if (validUniqueFilters.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Upsert requires values for at least one unique field: ${uniqueColumns.map((c) => c.name).join(', ')}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [existingRow] = await db
|
||||
.select()
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
or(...validUniqueFilters)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (!existingRow && table.rowCount >= table.maxRows) {
|
||||
return NextResponse.json(
|
||||
{ error: `Table row limit reached (${table.maxRows} rows max)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const upsertResult = await db.transaction(async (trx) => {
|
||||
if (existingRow) {
|
||||
const [updatedRow] = await trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: validated.data,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, existingRow.id))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
row: updatedRow,
|
||||
operation: 'update' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const [insertedRow] = await trx
|
||||
.insert(userTableRows)
|
||||
.values({
|
||||
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
tableId,
|
||||
workspaceId: validated.workspaceId,
|
||||
data: validated.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: authResult.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
row: insertedRow,
|
||||
operation: 'insert' as const,
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}`
|
||||
const upsertResult = await upsertRow(
|
||||
{
|
||||
tableId,
|
||||
workspaceId: validated.workspaceId,
|
||||
data: validated.data as RowData,
|
||||
userId: authResult.userId,
|
||||
conflictTarget: validated.conflictTarget,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -157,8 +66,14 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
|
||||
row: {
|
||||
id: upsertResult.row.id,
|
||||
data: upsertResult.row.data,
|
||||
createdAt: upsertResult.row.createdAt.toISOString(),
|
||||
updatedAt: upsertResult.row.updatedAt.toISOString(),
|
||||
createdAt:
|
||||
upsertResult.row.createdAt instanceof Date
|
||||
? upsertResult.row.createdAt.toISOString()
|
||||
: upsertResult.row.createdAt,
|
||||
updatedAt:
|
||||
upsertResult.row.updatedAt instanceof Date
|
||||
? upsertResult.row.updatedAt.toISOString()
|
||||
: upsertResult.row.updatedAt,
|
||||
},
|
||||
operation: upsertResult.operation,
|
||||
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
|
||||
@@ -172,11 +87,22 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error upserting row:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to upsert row: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
// Service layer throws descriptive errors for validation/capacity issues
|
||||
if (
|
||||
errorMessage.includes('unique column') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('conflictTarget') ||
|
||||
errorMessage.includes('row limit') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('Upsert requires') ||
|
||||
errorMessage.includes('Row size exceeds')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error upserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
canCreateTable,
|
||||
createTable,
|
||||
getWorkspaceTableLimits,
|
||||
listTables,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
type TableSchema,
|
||||
} from '@/lib/table'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { normalizeColumn } from './utils'
|
||||
import { normalizeColumn } from '@/app/api/table/utils'
|
||||
|
||||
const logger = createLogger('TableAPI')
|
||||
|
||||
@@ -101,7 +100,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const params = CreateTableSchema.parse(body)
|
||||
|
||||
const { hasAccess, canWrite } = await checkWorkspaceAccess(
|
||||
@@ -113,22 +118,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check billing plan limits
|
||||
const existingTables = await listTables(params.workspaceId)
|
||||
const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length)
|
||||
|
||||
if (!canCreate) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get plan-based row limits
|
||||
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
|
||||
const maxRowsPerTable = planLimits.maxRowsPerTable
|
||||
|
||||
const normalizedSchema: TableSchema = {
|
||||
columns: params.schema.columns.map(normalizeColumn),
|
||||
@@ -141,7 +131,8 @@ export async function POST(request: NextRequest) {
|
||||
schema: normalizedSchema,
|
||||
workspaceId: params.workspaceId,
|
||||
userId: authResult.userId,
|
||||
maxRows: maxRowsPerTable,
|
||||
maxRows: planLimits.maxRowsPerTable,
|
||||
maxTables: planLimits.maxTables,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
@@ -153,7 +144,9 @@ export async function POST(request: NextRequest) {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: table.schema,
|
||||
schema: {
|
||||
columns: (table.schema as TableSchema).columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
@@ -177,11 +170,13 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('maximum table limit')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
if (
|
||||
error.message.includes('Invalid table name') ||
|
||||
error.message.includes('Invalid schema') ||
|
||||
error.message.includes('already exists') ||
|
||||
error.message.includes('maximum table limit')
|
||||
error.message.includes('already exists')
|
||||
) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
@@ -231,10 +226,14 @@ export async function GET(request: NextRequest) {
|
||||
tables: tables.map((t) => {
|
||||
const schemaData = t.schema as TableSchema
|
||||
return {
|
||||
...t,
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: t.rowCount,
|
||||
maxRows: t.maxRows,
|
||||
createdAt:
|
||||
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||
updatedAt:
|
||||
|
||||
@@ -31,7 +31,7 @@ const SettingsSchema = z.object({
|
||||
})
|
||||
|
||||
const defaultSettings = {
|
||||
theme: 'dark',
|
||||
theme: 'system',
|
||||
autoConnect: true,
|
||||
telemetryEnabled: true,
|
||||
emailPreferences: {},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
|
||||
@@ -75,8 +74,6 @@ export async function POST(req: NextRequest) {
|
||||
model: selectedModel,
|
||||
mode: transportMode,
|
||||
messageId: crypto.randomUUID(),
|
||||
version: SIM_AGENT_VERSION,
|
||||
headless: true,
|
||||
chatId,
|
||||
}
|
||||
|
||||
@@ -84,6 +81,7 @@ export async function POST(req: NextRequest) {
|
||||
userId: auth.userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: parsed.autoExecuteTools,
|
||||
timeout: parsed.timeout,
|
||||
interactive: false,
|
||||
@@ -93,8 +91,7 @@ export async function POST(req: NextRequest) {
|
||||
success: result.success,
|
||||
content: result.content,
|
||||
toolCalls: result.toolCalls,
|
||||
chatId: result.chatId || chatId, // Return the chatId for conversation continuity
|
||||
conversationId: result.conversationId,
|
||||
chatId: result.chatId || chatId,
|
||||
error: result.error,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
158
apps/sim/app/api/v1/files/[fileId]/route.ts
Normal file
158
apps/sim/app/api/v1/files/[fileId]/route.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
deleteWorkspaceFile,
|
||||
downloadWorkspaceFile,
|
||||
getWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1FileDetailAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const WorkspaceIdSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'workspaceId query parameter is required'),
|
||||
})
|
||||
|
||||
interface FileRouteParams {
|
||||
params: Promise<{ fileId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/files/[fileId] — Download file content. */
|
||||
export async function GET(request: NextRequest, { params }: FileRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'file-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { fileId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const validation = WorkspaceIdSchema.safeParse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: validation.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { workspaceId } = validation.data
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission === null) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const buffer = await downloadWorkspaceFile(fileRecord)
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': fileRecord.type || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
|
||||
'Content-Length': String(buffer.length),
|
||||
'X-File-Id': fileRecord.id,
|
||||
'X-File-Name': encodeURIComponent(fileRecord.name),
|
||||
'X-Uploaded-At':
|
||||
fileRecord.uploadedAt instanceof Date
|
||||
? fileRecord.uploadedAt.toISOString()
|
||||
: String(fileRecord.uploadedAt),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error downloading file:`, error)
|
||||
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/files/[fileId] — Delete a file. */
|
||||
export async function DELETE(request: NextRequest, { params }: FileRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'file-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { fileId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const validation = WorkspaceIdSchema.safeParse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: validation.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { workspaceId } = validation.data
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission === null || permission === 'read') {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await deleteWorkspaceFile(workspaceId, fileId)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleted file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.FILE_DELETED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: fileRecord.name,
|
||||
description: `Deleted file "${fileRecord.name}" via API`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'File deleted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting file:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
194
apps/sim/app/api/v1/files/route.ts
Normal file
194
apps/sim/app/api/v1/files/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
getWorkspaceFile,
|
||||
listWorkspaceFiles,
|
||||
uploadWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1FilesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
|
||||
|
||||
const ListFilesSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'workspaceId query parameter is required'),
|
||||
})
|
||||
|
||||
/** GET /api/v1/files — List all files in a workspace. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'files')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const validation = ListFilesSchema.safeParse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: validation.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { workspaceId } = validation.data
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission === null) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const files = await listWorkspaceFiles(workspaceId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
files: files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
key: f.key,
|
||||
uploadedBy: f.uploadedBy,
|
||||
uploadedAt:
|
||||
f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt),
|
||||
})),
|
||||
totalCount: files.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing files:`, error)
|
||||
return NextResponse.json({ error: 'Failed to list files' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/v1/files — Upload a file to a workspace. */
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'files')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await request.formData()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Request body must be valid multipart form data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const rawFile = formData.get('file')
|
||||
const file = rawFile instanceof File ? rawFile : null
|
||||
const rawWorkspaceId = formData.get('workspaceId')
|
||||
const workspaceId = typeof rawWorkspaceId === 'string' ? rawWorkspaceId : null
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'workspaceId form field is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'file form field is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission === null || permission === 'read') {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
|
||||
const userFile = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
userId,
|
||||
buffer,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream'
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Uploaded file: ${file.name} to workspace ${workspaceId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.FILE_UPLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: userFile.id,
|
||||
resourceName: file.name,
|
||||
description: `Uploaded file "${file.name}" via API`,
|
||||
request,
|
||||
})
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, userFile.id)
|
||||
const uploadedAt =
|
||||
fileRecord?.uploadedAt instanceof Date
|
||||
? fileRecord.uploadedAt.toISOString()
|
||||
: fileRecord?.uploadedAt
|
||||
? String(fileRecord.uploadedAt)
|
||||
: new Date().toISOString()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
file: {
|
||||
id: userFile.id,
|
||||
name: userFile.name,
|
||||
size: userFile.size,
|
||||
type: userFile.type,
|
||||
key: userFile.key,
|
||||
uploadedBy: userId,
|
||||
uploadedAt,
|
||||
},
|
||||
message: 'File uploaded successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to upload file'
|
||||
const isDuplicate = errorMessage.includes('already exists')
|
||||
|
||||
if (isDuplicate) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 409 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error uploading file:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import type { SubscriptionPlan } from '@/lib/core/rate-limiter'
|
||||
import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { authenticateV1Request } from '@/app/api/v1/auth'
|
||||
|
||||
const logger = createLogger('V1Middleware')
|
||||
@@ -14,12 +15,25 @@ export interface RateLimitResult {
|
||||
limit: number
|
||||
retryAfterMs?: number
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function checkRateLimit(
|
||||
request: NextRequest,
|
||||
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' | 'audit-logs' = 'logs'
|
||||
endpoint:
|
||||
| 'logs'
|
||||
| 'logs-detail'
|
||||
| 'workflows'
|
||||
| 'workflow-detail'
|
||||
| 'audit-logs'
|
||||
| 'tables'
|
||||
| 'table-detail'
|
||||
| 'table-rows'
|
||||
| 'table-row-detail'
|
||||
| 'files'
|
||||
| 'file-detail' = 'logs'
|
||||
): Promise<RateLimitResult> {
|
||||
try {
|
||||
const auth = await authenticateV1Request(request)
|
||||
@@ -51,20 +65,18 @@ export async function checkRateLimit(
|
||||
})
|
||||
}
|
||||
|
||||
const rateLimitStatus = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
userId,
|
||||
subscription,
|
||||
'api-endpoint',
|
||||
false
|
||||
)
|
||||
const plan = (subscription?.plan || 'free') as SubscriptionPlan
|
||||
const config = getRateLimit(plan, 'api-endpoint')
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
remaining: result.remaining,
|
||||
resetAt: result.resetAt,
|
||||
limit: rateLimitStatus.requestsPerMinute,
|
||||
limit: config.refillRate,
|
||||
retryAfterMs: result.retryAfterMs,
|
||||
userId,
|
||||
workspaceId: auth.workspaceId,
|
||||
keyType: auth.keyType,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Rate limit check error', { error })
|
||||
@@ -89,26 +101,40 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse {
|
||||
return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401, headers })
|
||||
}
|
||||
|
||||
if (!result.allowed) {
|
||||
const retryAfterSeconds = result.retryAfterMs
|
||||
? Math.ceil(result.retryAfterMs / 1000)
|
||||
: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)
|
||||
const retryAfterSeconds = result.retryAfterMs
|
||||
? Math.ceil(result.retryAfterMs / 1000)
|
||||
: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Rate limit exceeded',
|
||||
message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`,
|
||||
retryAfter: result.resetAt.getTime(),
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Rate limit exceeded',
|
||||
message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`,
|
||||
retryAfter: result.resetAt.getTime(),
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
...headers,
|
||||
'Retry-After': retryAfterSeconds.toString(),
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
...headers,
|
||||
'Retry-After': retryAfterSeconds.toString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Verify that a workspace-scoped API key is only used for its own workspace. */
|
||||
export function checkWorkspaceScope(
|
||||
rateLimit: RateLimitResult,
|
||||
requestedWorkspaceId: string
|
||||
): NextResponse | null {
|
||||
if (
|
||||
rateLimit.keyType === 'workspace' &&
|
||||
rateLimit.workspaceId &&
|
||||
rateLimit.workspaceId !== requestedWorkspaceId
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is not authorized for this workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Bad request' }, { status: 400, headers })
|
||||
return null
|
||||
}
|
||||
|
||||
142
apps/sim/app/api/v1/tables/[tableId]/route.ts
Normal file
142
apps/sim/app/api/v1/tables/[tableId]/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteTable, type TableSchema } from '@/lib/table'
|
||||
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableDetailAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface TableRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/tables/[tableId] — Get table details. */
|
||||
export async function GET(request: NextRequest, { params }: TableRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const schemaData = table.schema as TableSchema
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
? table.createdAt.toISOString()
|
||||
: String(table.createdAt),
|
||||
updatedAt:
|
||||
table.updatedAt instanceof Date
|
||||
? table.updatedAt.toISOString()
|
||||
: String(table.updatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/tables/[tableId] — Delete a table. */
|
||||
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
if (result.table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await deleteTable(tableId, requestId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.TABLE_DELETED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: tableId,
|
||||
resourceName: result.table.name,
|
||||
description: `Deleted table "${result.table.name}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Table deleted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
275
apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
Normal file
275
apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData } from '@/lib/table'
|
||||
import { updateRow } from '@/lib/table'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableRowAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const UpdateRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
interface RowRouteParams {
|
||||
params: Promise<{ tableId: string; rowId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/tables/[tableId]/rows/[rowId] — Get a single row. */
|
||||
export async function GET(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-row-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId, rowId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
if (result.table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
createdAt: userTableRows.createdAt,
|
||||
updatedAt: userTableRows.updatedAt,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt:
|
||||
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
||||
updatedAt:
|
||||
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /api/v1/tables/[tableId]/rows/[rowId] — Partial update a single row. */
|
||||
export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-row-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId, rowId } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = UpdateRowSchema.parse(body)
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing row to merge partial update
|
||||
const [existingRow] = await db
|
||||
.select({ data: userTableRows.data })
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const mergedData = {
|
||||
...(existingRow.data as RowData),
|
||||
...(validated.data as RowData),
|
||||
}
|
||||
|
||||
const updatedRow = await updateRow(
|
||||
{
|
||||
tableId,
|
||||
rowId,
|
||||
data: mergedData,
|
||||
workspaceId: validated.workspaceId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: updatedRow.id,
|
||||
data: updatedRow.data,
|
||||
createdAt:
|
||||
updatedRow.createdAt instanceof Date
|
||||
? updatedRow.createdAt.toISOString()
|
||||
: updatedRow.createdAt,
|
||||
updatedAt:
|
||||
updatedRow.updatedAt instanceof Date
|
||||
? updatedRow.updatedAt.toISOString()
|
||||
: updatedRow.updatedAt,
|
||||
},
|
||||
message: 'Row updated successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (errorMessage === 'Row not found') {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 404 })
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes('Row size exceeds') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('Cannot set unique column')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/tables/[tableId]/rows/[rowId] — Delete a single row. */
|
||||
export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-row-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId, rowId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
if (result.table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [deletedRow] = await db
|
||||
.delete(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deletedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Row deleted successfully',
|
||||
deletedCount: 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
597
apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
Normal file
597
apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
batchInsertRows,
|
||||
deleteRowsByFilter,
|
||||
deleteRowsByIds,
|
||||
insertRow,
|
||||
TABLE_LIMITS,
|
||||
USER_TABLE_ROWS_SQL_NAME,
|
||||
updateRowsByFilter,
|
||||
validateBatchRows,
|
||||
validateRowData,
|
||||
validateRowSize,
|
||||
} from '@/lib/table'
|
||||
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableRowsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const InsertRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
const BatchInsertRowsSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
rows: z
|
||||
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
|
||||
.min(1, 'At least one row is required')
|
||||
.max(1000, 'Cannot insert more than 1000 rows per batch'),
|
||||
})
|
||||
|
||||
const QueryRowsSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown()).optional(),
|
||||
sort: z.record(z.enum(['asc', 'desc'])).optional(),
|
||||
limit: z
|
||||
.preprocess(
|
||||
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
|
||||
z
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`)
|
||||
.optional()
|
||||
)
|
||||
.default(100),
|
||||
offset: z
|
||||
.preprocess(
|
||||
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
|
||||
z
|
||||
.number({ required_error: 'Offset must be a number' })
|
||||
.int('Offset must be an integer')
|
||||
.min(0, 'Offset must be 0 or greater')
|
||||
.optional()
|
||||
)
|
||||
.default(0),
|
||||
})
|
||||
|
||||
const nonEmptyFilter = z
|
||||
.record(z.unknown(), { required_error: 'Filter criteria is required' })
|
||||
.refine((f) => Object.keys(f).length > 0, { message: 'Filter must not be empty' })
|
||||
|
||||
const optionalPositiveLimit = (max: number, label: string) =>
|
||||
z.preprocess(
|
||||
(val) => (val === null || val === undefined || val === '' ? undefined : Number(val)),
|
||||
z
|
||||
.number()
|
||||
.int(`${label} must be an integer`)
|
||||
.min(1, `${label} must be at least 1`)
|
||||
.max(max, `Cannot ${label.toLowerCase()} more than ${max} rows per operation`)
|
||||
.optional()
|
||||
)
|
||||
|
||||
const UpdateRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: nonEmptyFilter,
|
||||
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
|
||||
limit: optionalPositiveLimit(1000, 'Limit'),
|
||||
})
|
||||
|
||||
const DeleteRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: nonEmptyFilter,
|
||||
limit: optionalPositiveLimit(1000, 'Limit'),
|
||||
})
|
||||
|
||||
const DeleteRowsByIdsSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
rowIds: z
|
||||
.array(z.string().min(1), { required_error: 'Row IDs are required' })
|
||||
.min(1, 'At least one row ID is required')
|
||||
.max(1000, 'Cannot delete more than 1000 rows per operation'),
|
||||
})
|
||||
|
||||
const DeleteRowsRequestSchema = z.union([DeleteRowsByFilterSchema, DeleteRowsByIdsSchema])
|
||||
|
||||
interface TableRowsRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
async function handleBatchInsert(
|
||||
requestId: string,
|
||||
tableId: string,
|
||||
validated: z.infer<typeof BatchInsertRowsSchema>,
|
||||
userId: string
|
||||
): Promise<NextResponse> {
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validation = await validateBatchRows({
|
||||
rows: validated.rows as RowData[],
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
try {
|
||||
const insertedRows = await batchInsertRows(
|
||||
{
|
||||
tableId,
|
||||
rows: validated.rows as RowData[],
|
||||
workspaceId: validated.workspaceId,
|
||||
userId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: insertedRows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
|
||||
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
|
||||
})),
|
||||
insertedCount: insertedRows.length,
|
||||
message: `Successfully inserted ${insertedRows.length} rows`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (
|
||||
errorMessage.includes('row limit') ||
|
||||
errorMessage.includes('Insufficient capacity') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Row size exceeds') ||
|
||||
errorMessage.match(/^Row \d+:/)
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error batch inserting rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/v1/tables/[tableId]/rows — Query rows with filtering, sorting, pagination. */
|
||||
export async function GET(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
let filter: Record<string, unknown> | undefined
|
||||
let sort: Sort | undefined
|
||||
|
||||
try {
|
||||
const filterParam = searchParams.get('filter')
|
||||
const sortParam = searchParams.get('sort')
|
||||
if (filterParam) {
|
||||
filter = JSON.parse(filterParam) as Record<string, unknown>
|
||||
}
|
||||
if (sortParam) {
|
||||
sort = JSON.parse(sortParam) as Sort
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = QueryRowsSchema.parse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
filter,
|
||||
sort,
|
||||
limit: searchParams.get('limit'),
|
||||
offset: searchParams.get('offset'),
|
||||
})
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'read')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
if (validated.filter) {
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
let query = db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
createdAt: userTableRows.createdAt,
|
||||
updatedAt: userTableRows.updatedAt,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.sort) {
|
||||
const schema = table.schema as TableSchema
|
||||
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
|
||||
if (sortClause) {
|
||||
query = query.orderBy(sortClause) as typeof query
|
||||
}
|
||||
} else {
|
||||
query = query.orderBy(userTableRows.createdAt) as typeof query
|
||||
}
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
const [countResult, rows] = await Promise.all([
|
||||
countQuery,
|
||||
query.limit(validated.limit).offset(validated.offset),
|
||||
])
|
||||
const totalCount = countResult[0].count
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
|
||||
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
totalCount: Number(totalCount),
|
||||
limit: validated.limit,
|
||||
offset: validated.offset,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error querying rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/v1/tables/[tableId]/rows — Insert row(s). Supports single or batch. */
|
||||
export async function POST(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
'rows' in body &&
|
||||
Array.isArray((body as Record<string, unknown>).rows)
|
||||
) {
|
||||
const batchValidated = BatchInsertRowsSchema.parse(body)
|
||||
const scopeError = checkWorkspaceScope(rateLimit, batchValidated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
return handleBatchInsert(requestId, tableId, batchValidated, userId)
|
||||
}
|
||||
|
||||
const validated = InsertRowSchema.parse(body)
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const rowData = validated.data as RowData
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const row = await insertRow(
|
||||
{
|
||||
tableId,
|
||||
data: rowData,
|
||||
workspaceId: validated.workspaceId,
|
||||
userId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
|
||||
},
|
||||
message: 'Row inserted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (
|
||||
errorMessage.includes('row limit') ||
|
||||
errorMessage.includes('Insufficient capacity') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Row size exceeds')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error inserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/v1/tables/[tableId]/rows — Bulk update rows by filter. */
|
||||
export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = UpdateRowsByFilterSchema.parse(body)
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const sizeValidation = validateRowSize(validated.data as RowData)
|
||||
if (!sizeValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: sizeValidation.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await updateRowsByFilter(
|
||||
{
|
||||
tableId,
|
||||
filter: validated.filter as Filter,
|
||||
data: validated.data as RowData,
|
||||
limit: validated.limit,
|
||||
workspaceId: validated.workspaceId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (result.affectedCount === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'No rows matched the filter criteria',
|
||||
updatedCount: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows updated successfully',
|
||||
updatedCount: result.affectedCount,
|
||||
updatedRowIds: result.affectedRowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (
|
||||
errorMessage.includes('Row size exceeds') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('Cannot set unique column') ||
|
||||
errorMessage.includes('Filter is required')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating rows by filter:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/tables/[tableId]/rows — Delete rows by filter or IDs. */
|
||||
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = DeleteRowsRequestSchema.parse(body)
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
if ('rowIds' in validated) {
|
||||
const result = await deleteRowsByIds(
|
||||
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message:
|
||||
result.deletedCount === 0
|
||||
? 'No matching rows found for the provided IDs'
|
||||
: 'Rows deleted successfully',
|
||||
deletedCount: result.deletedCount,
|
||||
deletedRowIds: result.deletedRowIds,
|
||||
requestedCount: result.requestedCount,
|
||||
...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await deleteRowsByFilter(
|
||||
{
|
||||
tableId,
|
||||
filter: validated.filter as Filter,
|
||||
limit: validated.limit,
|
||||
workspaceId: validated.workspaceId,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message:
|
||||
result.affectedCount === 0
|
||||
? 'No rows matched the filter criteria'
|
||||
: 'Rows deleted successfully',
|
||||
deletedCount: result.affectedCount,
|
||||
deletedRowIds: result.affectedRowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (errorMessage.includes('Filter is required')) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
119
apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
Normal file
119
apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData } from '@/lib/table'
|
||||
import { upsertRow } from '@/lib/table'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableUpsertAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const UpsertRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
conflictTarget: z.string().optional(),
|
||||
})
|
||||
|
||||
interface UpsertRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** POST /api/v1/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */
|
||||
export async function POST(request: NextRequest, { params }: UpsertRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { tableId } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = UpsertRowSchema.parse(body)
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const upsertResult = await upsertRow(
|
||||
{
|
||||
tableId,
|
||||
workspaceId: validated.workspaceId,
|
||||
data: validated.data as RowData,
|
||||
userId,
|
||||
conflictTarget: validated.conflictTarget,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: upsertResult.row.id,
|
||||
data: upsertResult.row.data,
|
||||
createdAt:
|
||||
upsertResult.row.createdAt instanceof Date
|
||||
? upsertResult.row.createdAt.toISOString()
|
||||
: upsertResult.row.createdAt,
|
||||
updatedAt:
|
||||
upsertResult.row.updatedAt instanceof Date
|
||||
? upsertResult.row.updatedAt.toISOString()
|
||||
: upsertResult.row.updatedAt,
|
||||
},
|
||||
operation: upsertResult.operation,
|
||||
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (
|
||||
errorMessage.includes('unique column') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('conflictTarget') ||
|
||||
errorMessage.includes('row limit') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('Upsert requires') ||
|
||||
errorMessage.includes('Row size exceeds')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error upserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
260
apps/sim/app/api/v1/tables/route.ts
Normal file
260
apps/sim/app/api/v1/tables/route.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
createTable,
|
||||
getWorkspaceTableLimits,
|
||||
listTables,
|
||||
TABLE_LIMITS,
|
||||
type TableSchema,
|
||||
} from '@/lib/table'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { normalizeColumn } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TablesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const ListTablesSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'workspaceId query parameter is required'),
|
||||
})
|
||||
|
||||
const ColumnSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Column name is required')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
|
||||
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
|
||||
)
|
||||
.regex(
|
||||
/^[a-z_][a-z0-9_]*$/i,
|
||||
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
|
||||
),
|
||||
type: z.enum(['string', 'number', 'boolean', 'date', 'json'], {
|
||||
errorMap: () => ({
|
||||
message: 'Column type must be one of: string, number, boolean, date, json',
|
||||
}),
|
||||
}),
|
||||
required: z.boolean().optional().default(false),
|
||||
unique: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
const CreateTableSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Table name is required')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
|
||||
`Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less`
|
||||
)
|
||||
.regex(
|
||||
/^[a-z_][a-z0-9_]*$/i,
|
||||
'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores'
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_DESCRIPTION_LENGTH,
|
||||
`Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`
|
||||
)
|
||||
.optional(),
|
||||
schema: z.object({
|
||||
columns: z
|
||||
.array(ColumnSchema)
|
||||
.min(1, 'Table must have at least one column')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
|
||||
`Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
|
||||
),
|
||||
}),
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
/** GET /api/v1/tables — List all tables in a workspace. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'tables')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const validation = ListTablesSchema.safeParse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: validation.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { workspaceId } = validation.data
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission === null) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const tables = await listTables(workspaceId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tables: tables.map((t) => {
|
||||
const schemaData = t.schema as TableSchema
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: t.rowCount,
|
||||
maxRows: t.maxRows,
|
||||
createdAt:
|
||||
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||
updatedAt:
|
||||
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
|
||||
}
|
||||
}),
|
||||
totalCount: tables.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error listing tables:`, error)
|
||||
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/v1/tables — Create a new table. */
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'tables')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const params = CreateTableSchema.parse(body)
|
||||
|
||||
const scopeError = checkWorkspaceScope(rateLimit, params.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', params.workspaceId)
|
||||
if (permission === null || permission === 'read') {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
|
||||
|
||||
const normalizedSchema: TableSchema = {
|
||||
columns: params.schema.columns.map(normalizeColumn),
|
||||
}
|
||||
|
||||
const table = await createTable(
|
||||
{
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
schema: normalizedSchema,
|
||||
workspaceId: params.workspaceId,
|
||||
userId,
|
||||
maxRows: planLimits.maxRowsPerTable,
|
||||
maxTables: planLimits.maxTables,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.TABLE_CREATED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: table.id,
|
||||
resourceName: table.name,
|
||||
description: `Created table "${table.name}" via API`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: {
|
||||
columns: (table.schema as TableSchema).columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
? table.createdAt.toISOString()
|
||||
: String(table.createdAt),
|
||||
updatedAt:
|
||||
table.updatedAt instanceof Date
|
||||
? table.updatedAt.toISOString()
|
||||
: String(table.updatedAt),
|
||||
},
|
||||
message: 'Table created successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('maximum table limit')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
if (
|
||||
error.message.includes('Invalid table name') ||
|
||||
error.message.includes('Invalid schema') ||
|
||||
error.message.includes('already exists')
|
||||
) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error creating table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -108,6 +111,49 @@ const WorkflowStateSchema = z.object({
|
||||
variables: z.any().optional(), // Workflow variables
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/workflows/[id]/state
|
||||
* Fetch the current workflow state from normalized tables.
|
||||
* Used by the client after server-side edits (edit_workflow) to stay in sync.
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: auth.userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
blocks: normalized.blocks,
|
||||
edges: normalized.edges,
|
||||
loops: normalized.loops || {},
|
||||
parallels: normalized.parallels || {},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflow state', {
|
||||
workflowId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/workflows/[id]/state
|
||||
* Save complete workflow state to normalized database tables
|
||||
|
||||
@@ -240,7 +240,9 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/workspace/workspace-456/home'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page with token preserved when invitation expired', async () => {
|
||||
@@ -495,7 +497,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
|
||||
expect(response2.status).toBe(307)
|
||||
expect(response2.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/workspace/workspace-456/w'
|
||||
'https://test.sim.ai/workspace/workspace-456/home'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function GET(
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl())
|
||||
new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -193,7 +193,9 @@ export async function GET(
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
|
||||
return NextResponse.redirect(
|
||||
new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
var width = state && state.sidebarWidth;
|
||||
var maxSidebarWidth = window.innerWidth * 0.3;
|
||||
|
||||
if (width >= 232 && width <= maxSidebarWidth) {
|
||||
if (width >= 248 && width <= maxSidebarWidth) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
|
||||
} else if (width > maxSidebarWidth) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
|
||||
|
||||
@@ -3,18 +3,18 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
export async function GET() {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const llmsFullContent = `# Sim - AI Agent Workflow Builder
|
||||
const llmsFullContent = `# Sim — Build AI Agents & Run Your Agentic Workforce
|
||||
|
||||
> Sim is an open-source AI agent workflow builder used by 70,000+ developers at startups to Fortune 500 companies. Build and deploy agentic workflows with a visual drag-and-drop canvas. SOC2 and HIPAA compliant.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
Sim provides a visual interface for building AI agent workflows. Instead of writing code, users drag and drop blocks onto a canvas and connect them to create complex AI automations. Each block represents a step in the workflow - an LLM call, a tool invocation, an API request, or a code execution.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that execute real workflows across systems, and manage them with full observability. SOC2 and HIPAA compliant.
|
||||
|
||||
## Product Details
|
||||
|
||||
- **Product Name**: Sim
|
||||
- **Category**: AI Development Tools / Workflow Automation
|
||||
- **Category**: AI Agent Platform / Agentic Workflow Orchestration
|
||||
- **Deployment**: Cloud (SaaS) and Self-hosted options
|
||||
- **Pricing**: Free tier, Pro ($20/month), Team ($40/month), Enterprise (custom)
|
||||
- **Compliance**: SOC2 Type II, HIPAA compliant
|
||||
@@ -66,7 +66,7 @@ Sim supports all major LLM providers:
|
||||
- Amazon Bedrock
|
||||
|
||||
### Integrations
|
||||
100+ pre-built integrations including:
|
||||
1,000+ pre-built integrations including:
|
||||
- **Communication**: Slack, Discord, Email (Gmail, Outlook), SMS (Twilio)
|
||||
- **Productivity**: Notion, Airtable, Google Sheets, Google Docs
|
||||
- **Development**: GitHub, GitLab, Jira, Linear
|
||||
@@ -81,6 +81,12 @@ Built-in support for:
|
||||
- Semantic search and retrieval
|
||||
- Chunking strategies (fixed size, semantic, recursive)
|
||||
|
||||
### Tables
|
||||
Built-in table creation and management:
|
||||
- Structured data storage
|
||||
- Queryable tables for agent workflows
|
||||
- Native integrations
|
||||
|
||||
### Code Execution
|
||||
- Sandboxed JavaScript/TypeScript execution
|
||||
- Access to npm packages
|
||||
|
||||
@@ -5,16 +5,16 @@ export async function GET() {
|
||||
|
||||
const llmsContent = `# Sim
|
||||
|
||||
> Sim is an open-source AI agent workflow builder. 70,000+ developers at startups to Fortune 500 companies deploy agentic workflows on the Sim platform. SOC2 and HIPAA compliant.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
Sim provides a visual drag-and-drop interface for building and deploying AI agent workflows. Connect to 100+ integrations and ship production-ready AI automations.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 and HIPAA compliant.
|
||||
|
||||
## Core Pages
|
||||
|
||||
- [Homepage](${baseUrl}): Main landing page with product overview and features
|
||||
- [Homepage](${baseUrl}): Product overview, features, and pricing
|
||||
- [Templates](${baseUrl}/templates): Pre-built workflow templates to get started quickly
|
||||
- [Changelog](${baseUrl}/changelog): Product updates and release notes
|
||||
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides for AI workflows
|
||||
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -29,28 +29,31 @@ Sim provides a visual drag-and-drop interface for building and deploying AI agen
|
||||
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
|
||||
- **Trigger**: Event or schedule that initiates workflow execution
|
||||
- **Execution**: A single run of a workflow with logs and outputs
|
||||
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Visual workflow builder with drag-and-drop canvas
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI)
|
||||
- Retrieval-augmented generation (RAG) with vector databases
|
||||
- 100+ integrations (Slack, Gmail, Notion, Airtable, databases)
|
||||
- AI agent creation and deployment
|
||||
- Agentic workflow orchestration
|
||||
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
|
||||
- Knowledge base creation with retrieval-augmented generation (RAG)
|
||||
- Table creation and management
|
||||
- Document creation and processing
|
||||
- Scheduled and webhook-triggered executions
|
||||
- Real-time collaboration and version control
|
||||
|
||||
## Use Cases
|
||||
|
||||
- AI agent workflow automation
|
||||
- RAG pipelines and document processing
|
||||
- Chatbot and copilot workflows for SaaS
|
||||
- Email and customer support automation
|
||||
- AI agent deployment and orchestration
|
||||
- Knowledge bases and RAG pipelines
|
||||
- Document creation and processing
|
||||
- Customer support automation
|
||||
- Internal operations (sales, marketing, legal, finance)
|
||||
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with users
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
|
||||
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
|
||||
|
||||
## Optional
|
||||
|
||||
@@ -5,10 +5,10 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return {
|
||||
name: brand.name === 'Sim' ? 'Sim - AI Agent Workflow Builder' : brand.name,
|
||||
name: brand.name === 'Sim' ? 'Sim — Build AI Agents & Run Your Agentic Workforce' : brand.name,
|
||||
short_name: brand.name,
|
||||
description:
|
||||
'Open-source AI agent workflow builder. 70,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. SOC2 and HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Landing from '@/app/(landing)/landing'
|
||||
import Landing from '@/app/(home)/landing'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source Platform',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by 70,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
keywords:
|
||||
'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI',
|
||||
'AI agents, agentic workforce, open-source AI agent platform, agentic workflows, LLM orchestration, AI automation, knowledge base, workflow builder, AI integrations, SOC2 compliant, HIPAA compliant, enterprise AI',
|
||||
authors: [{ name: 'Sim' }],
|
||||
creator: 'Sim',
|
||||
publisher: 'Sim',
|
||||
@@ -20,9 +22,9 @@ export const metadata: Metadata = {
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source platform used by 70,000+ developers. Design, deploy, and monitor agentic workflows with a visual drag-and-drop interface, 100+ integrations, and enterprise-grade security.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Join over 100,000 builders.',
|
||||
type: 'website',
|
||||
url: baseUrl,
|
||||
siteName: 'Sim',
|
||||
@@ -32,7 +34,7 @@ export const metadata: Metadata = {
|
||||
url: '/logo/426-240/primary/small.png',
|
||||
width: 2130,
|
||||
height: 1200,
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
@@ -41,12 +43,12 @@ export const metadata: Metadata = {
|
||||
card: 'summary_large_image',
|
||||
site: '@simdotai',
|
||||
creator: '@simdotai',
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source platform for agentic workflows. 70,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
images: {
|
||||
url: '/logo/426-240/primary/small.png',
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
@@ -72,11 +74,12 @@ export const metadata: Metadata = {
|
||||
classification: 'AI Development Tools',
|
||||
referrer: 'origin-when-cross-origin',
|
||||
other: {
|
||||
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
|
||||
'llm:content-type':
|
||||
'AI agent platform, agentic workforce, agentic workflows, LLM orchestration',
|
||||
'llm:use-cases':
|
||||
'email automation, Slack bots, Discord moderation, data analysis, customer support, content generation, agentic automations',
|
||||
'AI agents, agentic workforce, agentic workflows, knowledge bases, tables, document creation, email automation, Slack bots, data analysis, customer support, content generation',
|
||||
'llm:integrations':
|
||||
'OpenAI, Anthropic, Google AI, Slack, Gmail, Discord, Notion, Airtable, Supabase',
|
||||
'OpenAI, Anthropic, Google AI, Mistral, xAI, Perplexity, Slack, Gmail, Discord, Notion, Airtable, Supabase',
|
||||
'llm:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
|
||||
'llm:region': 'global',
|
||||
'llm:languages': 'en',
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { NextError } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
export default NextError
|
||||
const logger = createLogger('WorkspaceError')
|
||||
|
||||
interface WorkspaceErrorProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export default function WorkspaceError({ error, reset }: WorkspaceErrorProps) {
|
||||
useEffect(() => {
|
||||
logger.error('Workspace error:', { error: error.message, digest: error.digest })
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 items-center justify-center bg-white dark:bg-[var(--bg)]'>
|
||||
<div className='flex flex-col items-center gap-[16px] text-center'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
An unexpected error occurred. Please try again or refresh the page.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='default' size='sm' onClick={reset}>
|
||||
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ const PLAN_NAMES = {
|
||||
free: 'Free',
|
||||
} as const
|
||||
|
||||
export function Files() {
|
||||
export function FileList() {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
@@ -227,19 +227,19 @@ export function Files() {
|
||||
const displayPlanName = PLAN_NAMES[planName as keyof typeof PLAN_NAMES] || 'Free'
|
||||
|
||||
const renderTableSkeleton = () => (
|
||||
<Table className='table-fixed text-[13px]'>
|
||||
<Table className='table-fixed text-[14px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[56%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[56%] px-[12px] py-[8px] text-[13px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[40px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[28px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[56px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[48px]' />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -253,10 +253,10 @@ export function Files() {
|
||||
<Skeleton className='h-[14px] w-[180px]' />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px]'>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px]'>
|
||||
<Skeleton className='h-[12px] w-[48px]' />
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px]'>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px]'>
|
||||
<Skeleton className='h-[12px] w-[56px]' />
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
@@ -272,80 +272,80 @@ export function Files() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[2px]'>
|
||||
{/* Search Input and Upload Button */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Search and Actions */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
|
||||
'flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]',
|
||||
permissionsLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
<Input
|
||||
placeholder='Search files...'
|
||||
placeholder='Search'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={permissionsLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
/>
|
||||
</div>
|
||||
{(permissionsLoading || userPermissions.canEdit) && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
className='hidden'
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading || permissionsLoading}
|
||||
accept={ACCEPT_ATTR}
|
||||
multiple
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || permissionsLoading}
|
||||
variant='tertiary'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
{uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{(permissionsLoading || userPermissions.canEdit) && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
className='hidden'
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading || permissionsLoading}
|
||||
accept={ACCEPT_ATTR}
|
||||
multiple
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || permissionsLoading}
|
||||
variant='tertiary'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[14px] w-[14px]' />
|
||||
{uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{/* Content */}
|
||||
<div ref={scrollContainerRef} className='mt-[24px] min-h-0 flex-1 overflow-y-auto'>
|
||||
{permissionsLoading ? (
|
||||
renderTableSkeleton()
|
||||
) : files.length === 0 && failedFiles.length === 0 ? (
|
||||
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
|
||||
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
|
||||
No files uploaded yet
|
||||
</div>
|
||||
) : filteredFiles.length === 0 && failedFiles.length === 0 ? (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
|
||||
No files found matching "{search}"
|
||||
</div>
|
||||
) : (
|
||||
<Table className='table-fixed text-[13px]'>
|
||||
<Table className='table-fixed text-[14px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[56%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[56%] px-[12px] py-[8px] text-[13px] text-[var(--text-secondary)]'>
|
||||
Name
|
||||
</TableHead>
|
||||
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
Uploaded
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -362,17 +362,17 @@ export function Files() {
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-error)]' />
|
||||
<span
|
||||
className='min-w-0 truncate text-[14px] text-[var(--text-error)]'
|
||||
className='min-w-0 truncate text-[15px] text-[var(--text-error)]'
|
||||
title={fileName}
|
||||
>
|
||||
{truncateMiddle(fileName)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-error)]'>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-error)]'>
|
||||
—
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-error)]'>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-error)]'>
|
||||
—
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
@@ -398,17 +398,17 @@ export function Files() {
|
||||
<button
|
||||
onClick={() => handleDownload(file)}
|
||||
disabled={downloadingFileId === file.id}
|
||||
className='min-w-0 truncate text-left font-normal text-[14px] text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50'
|
||||
className='min-w-0 truncate text-left font-normal text-[15px] text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50'
|
||||
title={file.name}
|
||||
>
|
||||
{truncateMiddle(file.name)}
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
|
||||
{formatFileSize(file.size)}
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
|
||||
{formatDate(file.uploadedAt)}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
@@ -467,7 +467,7 @@ export function Files() {
|
||||
<div className='h-[14px] w-[1.5px] bg-[var(--divider)]' />
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Skeleton className='h-[12px] w-[40px] rounded-[2px]' />
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>/</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-tertiary)]'>/</span>
|
||||
<Skeleton className='h-[12px] w-[32px] rounded-[2px]' />
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,16 +483,16 @@ export function Files() {
|
||||
<div className='mt-auto flex flex-col gap-[8px] pt-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{displayPlanName}
|
||||
</span>
|
||||
<div className='h-[14px] w-[1.5px] bg-[var(--divider)]' />
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
{formatStorageSize(storageInfo.usedBytes)}
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>/</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-tertiary)]'>/</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
{formatStorageSize(storageInfo.limitBytes)}
|
||||
</span>
|
||||
</div>
|
||||
30
apps/sim/app/workspace/[workspaceId]/files/files.tsx
Normal file
30
apps/sim/app/workspace/[workspaceId]/files/files.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { Files as FilesIcon } from 'lucide-react'
|
||||
import { FileList } from '@/app/workspace/[workspaceId]/files/components/file-list'
|
||||
|
||||
export function Files() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#8B5CF6] bg-[#F5F3FF] dark:border-[#5B21B6] dark:bg-[#2E1065]'>
|
||||
<FilesIcon className='h-[14px] w-[14px] text-[#8B5CF6] dark:text-[#A78BFA]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Files</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||
Workspace files accessible across all workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search, Actions, and Content */}
|
||||
<FileList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
apps/sim/app/workspace/[workspaceId]/files/layout.tsx
Normal file
3
apps/sim/app/workspace/[workspaceId]/files/layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function FilesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
32
apps/sim/app/workspace/[workspaceId]/files/page.tsx
Normal file
32
apps/sim/app/workspace/[workspaceId]/files/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { Files } from './files'
|
||||
|
||||
interface FilesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function FilesPage({ params }: FilesPageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideFilesTab) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
return <Files />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { NextGlobalError } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
|
||||
|
||||
export default NextGlobalError
|
||||
@@ -0,0 +1,3 @@
|
||||
export { MessageContent } from './message-content'
|
||||
export { MothershipView } from './mothership-view'
|
||||
export { UserInput } from './user-input'
|
||||
@@ -0,0 +1 @@
|
||||
export { MessageContent } from './message-content'
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { ContentBlock, ToolCallStatus } from '../../types'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
const PROSE_CLASSES = cn(
|
||||
'prose prose-base dark:prose-invert max-w-none font-body font-[380]',
|
||||
'prose-headings:font-semibold prose-headings:tracking-[-0.01em] prose-headings:text-[var(--text-primary)]',
|
||||
'prose-headings:mb-[12px] prose-headings:mt-[20px]',
|
||||
'prose-p:text-[16px] prose-p:leading-[1.75] prose-p:tracking-[-0.015em] prose-p:text-[var(--text-primary)]',
|
||||
'prose-p:mb-[8px]',
|
||||
'prose-li:text-[16px] prose-li:leading-[1.75] prose-li:tracking-[-0.015em] prose-li:text-[var(--text-primary)]',
|
||||
'prose-li:my-[4px]',
|
||||
'prose-ul:my-[12px] prose-ol:my-[12px]',
|
||||
'prose-strong:font-semibold prose-strong:text-[var(--text-primary)]',
|
||||
'prose-a:text-[var(--brand-secondary)]',
|
||||
'prose-code:rounded-[4px] prose-code:bg-[var(--surface-5)] prose-code:px-[5px] prose-code:py-[2px] prose-code:text-[13px] prose-code:font-mono prose-code:font-normal prose-code:text-[var(--text-primary)]',
|
||||
'prose-pre:my-[14px] prose-pre:rounded-[8px] prose-pre:bg-[var(--surface-5)] prose-pre:text-[13px]',
|
||||
'prose-hr:border-[var(--divider)]'
|
||||
)
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name
|
||||
.replace(/_v\d+$/, '')
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
type: 'text'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ActionSegment {
|
||||
type: 'action'
|
||||
id: string
|
||||
label: string
|
||||
status: ToolCallStatus
|
||||
}
|
||||
|
||||
type MessageSegment = TextSegment | ActionSegment
|
||||
|
||||
/**
|
||||
* Flattens raw content blocks into a uniform list of text and action segments.
|
||||
* Tool calls and subagents are treated identically as action items.
|
||||
*/
|
||||
function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegment[] {
|
||||
const segments: MessageSegment[] = []
|
||||
const lastSubagentIdx = blocks.findLastIndex((b) => b.type === 'subagent')
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
if (block.content?.trim()) {
|
||||
const last = segments[segments.length - 1]
|
||||
if (last?.type === 'text') {
|
||||
last.content += block.content
|
||||
} else {
|
||||
segments.push({ type: 'text', content: block.content })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'subagent': {
|
||||
if (block.content) {
|
||||
segments.push({
|
||||
type: 'action',
|
||||
id: `subagent-${i}`,
|
||||
label: block.content,
|
||||
status: isStreaming && i === lastSubagentIdx ? 'executing' : 'success',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool_call': {
|
||||
if (block.toolCall) {
|
||||
segments.push({
|
||||
type: 'action',
|
||||
id: block.toolCall.id,
|
||||
label: block.toolCall.displayTitle || formatToolName(block.toolCall.name),
|
||||
status: block.toolCall.status,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
interface MessageContentProps {
|
||||
blocks: ContentBlock[]
|
||||
fallbackContent: string
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
export function MessageContent({ blocks, fallbackContent, isStreaming }: MessageContentProps) {
|
||||
const parsed = blocks.length > 0 ? parseBlocks(blocks, isStreaming) : []
|
||||
|
||||
const segments: MessageSegment[] =
|
||||
parsed.length > 0
|
||||
? parsed
|
||||
: fallbackContent?.trim()
|
||||
? [{ type: 'text' as const, content: fallbackContent }]
|
||||
: []
|
||||
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='space-y-[10px]'>
|
||||
{segments.map((segment, i) => {
|
||||
if (segment.type === 'text') {
|
||||
return (
|
||||
<div key={`text-${i}`} className={PROSE_CLASSES}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{segment.content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={segment.id} className='font-base text-[13px] text-[var(--text-tertiary)]'>
|
||||
{segment.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MothershipView } from './mothership-view'
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
export function MothershipView() {
|
||||
return (
|
||||
<div className='flex h-full w-[480px] flex-shrink-0 flex-col border-[var(--border)] border-l'>
|
||||
<div className='flex items-center border-[var(--border)] border-b px-[16px] py-[12px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Mothership</span>
|
||||
</div>
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>No artifacts yet</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UserInput } from './user-input'
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowUp, Mic, Paperclip } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useAnimatedPlaceholder } from '../../hooks'
|
||||
|
||||
const TEXTAREA_CLASSES = cn(
|
||||
'm-0 box-border h-auto max-h-[30vh] min-h-[24px] w-full resize-none',
|
||||
'overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent',
|
||||
'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
|
||||
'text-[var(--text-primary)] outline-none',
|
||||
'placeholder:font-[350] placeholder:text-[var(--text-subtle)]',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
)
|
||||
|
||||
const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'
|
||||
const SEND_BUTTON_ACTIVE =
|
||||
'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||
|
||||
function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, window.innerHeight * 0.3)}px`
|
||||
}
|
||||
|
||||
interface UserInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
isSending: boolean
|
||||
onStopGeneration: () => void
|
||||
isInitialView?: boolean
|
||||
}
|
||||
|
||||
export function UserInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSending,
|
||||
onStopGeneration,
|
||||
isInitialView = true,
|
||||
}: UserInputProps) {
|
||||
const animatedPlaceholder = useAnimatedPlaceholder()
|
||||
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
|
||||
const canSubmit = value.trim().length > 0 && !isSending
|
||||
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const prefixRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recognitionRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
},
|
||||
[onSubmit]
|
||||
)
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop()
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
return
|
||||
}
|
||||
|
||||
const w = window as Window & {
|
||||
SpeechRecognition?: typeof SpeechRecognition
|
||||
webkitSpeechRecognition?: typeof SpeechRecognition
|
||||
}
|
||||
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
|
||||
if (!SpeechRecognitionAPI) return
|
||||
|
||||
prefixRef.current = value
|
||||
|
||||
const recognition = new SpeechRecognitionAPI()
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
recognition.lang = 'en-US'
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let transcript = ''
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript
|
||||
}
|
||||
const prefix = prefixRef.current
|
||||
onChange(prefix ? `${prefix} ${transcript}` : transcript)
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (recognitionRef.current === recognition) {
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
|
||||
if (e.error === 'aborted' || e.error === 'not-allowed') {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
|
||||
recognitionRef.current = recognition
|
||||
recognition.start()
|
||||
setIsListening(true)
|
||||
}, [isListening, value, onChange])
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleContainerClick}
|
||||
className={cn(
|
||||
'mx-auto w-full max-w-[640px] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
|
||||
isInitialView && 'shadow-sm'
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className={TEXTAREA_CLASSES}
|
||||
/>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'>
|
||||
<Paperclip
|
||||
className='h-[14px] w-[14px] text-[var(--text-muted)] dark:text-[var(--text-secondary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={toggleListening}
|
||||
className={cn(
|
||||
'flex h-[28px] w-[28px] items-center justify-center rounded-full transition-colors',
|
||||
isListening
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'text-[var(--text-muted)] hover:bg-[#F7F7F7] dark:text-[var(--text-secondary)] dark:hover:bg-[#303030]'
|
||||
)}
|
||||
title={isListening ? 'Stop listening' : 'Voice input'}
|
||||
>
|
||||
<Mic className='h-[16px] w-[16px]' strokeWidth={2} />
|
||||
</button>
|
||||
{isSending ? (
|
||||
<Button
|
||||
onClick={onStopGeneration}
|
||||
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
|
||||
title='Stop generation'
|
||||
>
|
||||
<svg
|
||||
className='block h-[14px] w-[14px] fill-white dark:fill-black'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
||||
</svg>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
SEND_BUTTON_BASE,
|
||||
canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED
|
||||
)}
|
||||
>
|
||||
<ArrowUp
|
||||
className='block h-[16px] w-[16px] text-white dark:text-black'
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
apps/sim/app/workspace/[workspaceId]/home/home.tsx
Normal file
107
apps/sim/app/workspace/[workspaceId]/home/home.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { MessageContent, UserInput } from './components'
|
||||
import { useChat } from './hooks'
|
||||
|
||||
interface HomeProps {
|
||||
chatId?: string
|
||||
}
|
||||
|
||||
export function Home({ chatId }: HomeProps = {}) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const { messages, isSending, sendMessage, stopGeneration, chatBottomRef } = useChat(
|
||||
workspaceId,
|
||||
chatId
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = inputValue.trim()
|
||||
if (!trimmed) return
|
||||
setInputValue('')
|
||||
sendMessage(trimmed)
|
||||
}, [inputValue, sendMessage])
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
|
||||
if (!hasMessages) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center bg-[#FCFCFC] px-[24px] dark:bg-[var(--surface-2)]'>
|
||||
<h1 className='mb-[24px] font-[450] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
||||
What do you want to do?
|
||||
</h1>
|
||||
<UserInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={stopGeneration}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full bg-[#FCFCFC] dark:bg-[var(--surface-2)]'>
|
||||
<div className='flex h-full min-w-0 flex-1 flex-col'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-[24px] py-[16px]'>
|
||||
<div className='mx-auto max-w-[640px] space-y-[16px]'>
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className='flex justify-end'>
|
||||
<div className='max-w-[80%] rounded-[16px] bg-[var(--surface-5)] px-[14px] py-[4px]'>
|
||||
<p className='whitespace-pre-wrap font-[380] font-body text-[16px] text-[var(--text-primary)] leading-[1.75] tracking-[-0.015em]'>
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
|
||||
const isThisStreaming = isSending && msg === messages[messages.length - 1]
|
||||
|
||||
if (!hasBlocks && !msg.content && isThisStreaming) {
|
||||
return (
|
||||
<div key={msg.id} className='flex items-center gap-[6px] py-[8px]'>
|
||||
<div className='h-[6px] w-[6px] animate-pulse rounded-full bg-[var(--text-tertiary)]' />
|
||||
<span className='font-base text-[13px] text-[var(--text-tertiary)]'>
|
||||
Thinking…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasBlocks && !msg.content) return null
|
||||
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={chatBottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
|
||||
<UserInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={stopGeneration}
|
||||
isInitialView={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
Normal file
3
apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useAnimatedPlaceholder } from './use-animated-placeholder'
|
||||
export type { UseChatReturn } from './use-chat'
|
||||
export { useChat } from './use-chat'
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const PLACEHOLDER_PREFIX = 'Ask Sim to '
|
||||
const PLACEHOLDER_SUFFIXES = [
|
||||
'respond to my emails...',
|
||||
'find and track leads...',
|
||||
'DM me Linear updates on Slack...',
|
||||
'track GitHub commits...',
|
||||
] as const
|
||||
|
||||
const TYPE_SPEED_MS = 60
|
||||
const DELETE_SPEED_MS = 35
|
||||
const PAUSE_AFTER_TYPING_MS = 2000
|
||||
const PAUSE_AFTER_DELETING_MS = 400
|
||||
|
||||
export function useAnimatedPlaceholder(): string {
|
||||
const [text, setText] = useState(PLACEHOLDER_PREFIX)
|
||||
const stateRef = useRef({
|
||||
suffixIndex: 0,
|
||||
charIndex: 0,
|
||||
phase: 'typing' as 'typing' | 'paused' | 'deleting' | 'waiting',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const s = stateRef.current
|
||||
const suffix = PLACEHOLDER_SUFFIXES[s.suffixIndex]
|
||||
|
||||
switch (s.phase) {
|
||||
case 'typing': {
|
||||
s.charIndex++
|
||||
setText(PLACEHOLDER_PREFIX + suffix.slice(0, s.charIndex))
|
||||
if (s.charIndex >= suffix.length) {
|
||||
s.phase = 'paused'
|
||||
return PAUSE_AFTER_TYPING_MS
|
||||
}
|
||||
return TYPE_SPEED_MS
|
||||
}
|
||||
case 'paused': {
|
||||
s.phase = 'deleting'
|
||||
return DELETE_SPEED_MS
|
||||
}
|
||||
case 'deleting': {
|
||||
s.charIndex--
|
||||
setText(PLACEHOLDER_PREFIX + suffix.slice(0, s.charIndex))
|
||||
if (s.charIndex <= 0) {
|
||||
s.phase = 'waiting'
|
||||
return PAUSE_AFTER_DELETING_MS
|
||||
}
|
||||
return DELETE_SPEED_MS
|
||||
}
|
||||
case 'waiting': {
|
||||
s.suffixIndex = (s.suffixIndex + 1) % PLACEHOLDER_SUFFIXES.length
|
||||
s.charIndex = 0
|
||||
s.phase = 'typing'
|
||||
return TYPE_SPEED_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const schedule = () => {
|
||||
const delay = tick()
|
||||
timer = setTimeout(schedule, delay)
|
||||
}
|
||||
timer = setTimeout(schedule, TYPE_SPEED_MS)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return text
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user