Compare commits

...

14 Commits

Author SHA1 Message Date
Emir Karabeg
49c29d5f7d feat: pricing, collaboration improvement, features skeleton 2026-02-25 16:28:56 -08:00
Emir Karabeg
843af915bc feat: integrations skeleton, realtime complete 2026-02-25 16:28:56 -08:00
Emir Karabeg
bb3e899f74 feat(landing): template, generic workflow 2026-02-25 16:28:56 -08:00
Emir Karabeg
e47dcdcc43 feat(landing): navbar, metadata, hero, templates header 2026-02-25 16:28:55 -08:00
Emir Karabeg
3e6cf24762 feat(landing): structure 2026-02-25 16:28:55 -08:00
Waleed
244e1ee495 feat(workflow): lock/unlock workflow from context menu and panel (#3336)
* feat(workflow): lock/unlock workflow from context menu and panel

* lint

* fix(workflow): prevent duplicate lock notifications, no-op guard, fix orphaned JSDoc

* improvement(workflow): memoize hasLockedBlocks to avoid inline recomputation

* feat(google-translate): add Google Translate integration (#3337)

* feat(google-translate): add Google Translate integration

* fix(google-translate): api key as query param, fix docsLink, rename tool file

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338)

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar

* fix(google-drive): remove dead transformResponse from move tool

* feat(confluence): return page content in get page version tool (#3344)

* feat(confluence): return page content in get page version tool

* lint

* feat(api): audit log read endpoints for admin and enterprise (#3343)

* feat(api): audit log read endpoints for admin and enterprise

* fix(api): address PR review — boolean coercion, cursor validation, detail scope

* ran lint

* unified list of languages for google translate

* fix(workflow): respect snapshot view for panel lock toggle, remove unused disableAdmin prop

* improvement(canvas-menu): remove lock icon from workflow lock toggle

* feat(audit): record audit log for workflow lock/unlock
2026-02-25 15:23:30 -08:00
Waleed
1f3dc52d15 feat(api): audit log read endpoints for admin and enterprise (#3343)
* feat(api): audit log read endpoints for admin and enterprise

* fix(api): address PR review — boolean coercion, cursor validation, detail scope

* ran lint
2026-02-25 13:46:37 -08:00
Waleed
f625482bcb feat(confluence): return page content in get page version tool (#3344)
* feat(confluence): return page content in get page version tool

* lint
2026-02-25 13:45:19 -08:00
Waleed
16f337f6fd feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338)
* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar

* fix(google-drive): remove dead transformResponse from move tool
2026-02-25 13:38:35 -08:00
Waleed
063ec87ced feat(google-translate): add Google Translate integration (#3337)
* feat(google-translate): add Google Translate integration

* fix(google-translate): api key as query param, fix docsLink, rename tool file
2026-02-25 13:24:22 -08:00
Waleed
870d4b55c6 fix(templates): show description tagline on template cards (#3335) 2026-02-25 12:10:22 -08:00
Waleed
95304b2941 feat(google-sheets): add filter support to read operation (#3333)
* feat(google-sheets): add filter support to read operation

* ran lint
2026-02-25 11:34:12 -08:00
Waleed
8b0c47b06c chore(executor): extract shared utils and remove dead code from handlers (#3334) 2026-02-25 11:28:16 -08:00
Vikhyath Mondreti
774771fddd fix(call-chain): x-sim-via propagation for API blocks and MCP tools (#3332)
* fix(call-chain): x-sim-via propagation for API blocks and MCP tools

* addres bugbot comment
2026-02-25 08:41:54 -08:00
132 changed files with 8556 additions and 867 deletions

View File

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

4
.gitignore vendored
View File

@@ -73,3 +73,7 @@ start-collector.sh
## Helm Chart Tests
helm/sim/test
i18n.cache
## Claude Code
.claude/launch.json
.claude/worktrees/

View File

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

View File

@@ -264,15 +264,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()
@@ -282,7 +284,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',
@@ -303,7 +306,8 @@ export async function generateMetadata(props: {
card: 'summary_large_image',
title: data.title,
description:
data.description || 'Sim visual workflow builder for AI applications documentation',
data.description ||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
images: [ogImageUrl],
creator: '@simdotai',
site: '@simdotai',

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,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 60,000 builders.</div>
<div className='text-muted-foreground'>Trusted by over 100,000 builders.</div>
<div className='text-muted-foreground'>
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
The open-source platform to build AI agents and run your agentic workforce.
</div>
<Link
href='https://sim.ai/signup'

View File

@@ -5445,6 +5445,34 @@ export function GoogleMapsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTranslateIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 998.1 998.3'>
<path
fill='#DBDBDB'
d='M931.7 998.3c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4H283.6l260.1 797.9h388z'
/>
<path
fill='#DCDCDC'
d='M931.7 230.4c9.7 0 18.9 3.8 25.8 10.6 6.8 6.7 10.6 15.5 10.6 24.8v667.1c0 9.3-3.7 18.1-10.6 24.8-6.9 6.8-16.1 10.6-25.8 10.6H565.5L324.9 230.4h606.8m0-30H283.6l260.1 797.9h388c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4z'
/>
<polygon fill='#4352B8' points='482.3,809.8 543.7,998.3 714.4,809.8' />
<path
fill='#607988'
d='M936.1 476.1V437H747.6v-63.2h-61.2V437H566.1v39.1h239.4c-12.8 45.1-41.1 87.7-68.7 120.8-48.9-57.9-49.1-76.7-49.1-76.7h-50.8s2.1 28.2 70.7 108.6c-22.3 22.8-39.2 36.3-39.2 36.3l15.6 48.8s23.6-20.3 53.1-51.6c29.6 32.1 67.8 70.7 117.2 116.7l32.1-32.1c-52.9-48-91.7-86.1-120.2-116.7 38.2-45.2 77-102.1 85.2-154.2H936v.1z'
/>
<path
fill='#4285F4'
d='M66.4 0C29.9 0 0 29.9 0 66.5v677c0 36.5 29.9 66.4 66.4 66.4h648.1L454.4 0h-388z'
/>
<path
fill='#EEEEEE'
d='M371.4 430.6c-2.5 30.3-28.4 75.2-91.1 75.2-54.3 0-98.3-44.9-98.3-100.2s44-100.2 98.3-100.2c30.9 0 51.5 13.4 63.3 24.3l41.2-39.6c-27.1-25-62.4-40.6-104.5-40.6-86.1 0-156 69.9-156 156s69.9 156 156 156c90.2 0 149.8-63.3 149.8-152.6 0-12.8-1.6-22.2-3.7-31.8h-146v53.4l91 .1z'
/>
</svg>
)
}
export function DsPyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='30 28 185 175' fill='none'>

View File

@@ -74,7 +74,7 @@ export function StructuredData({
name: 'Sim Documentation',
url: baseUrl,
description:
'Comprehensive documentation for Sim visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
publisher: {
'@type': 'Organization',
name: 'Sim',
@@ -104,7 +104,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',
@@ -115,12 +115,13 @@ export function StructuredData({
category: 'Developer Tools',
},
featureList: [
'Visual workflow builder with drag-and-drop interface',
'AI agent creation and automation',
'80+ built-in integrations',
'Real-time team collaboration',
'Multiple deployment options',
'Custom integrations via MCP protocol',
'AI agent creation',
'Agentic workflow orchestration',
'1,000+ integrations',
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Knowledge base creation',
'Table creation',
'Document creation',
],
}

View File

@@ -52,6 +52,7 @@ import {
GoogleMapsIcon,
GoogleSheetsIcon,
GoogleSlidesIcon,
GoogleTranslateIcon,
GoogleVaultIcon,
GrafanaIcon,
GrainIcon,
@@ -197,6 +198,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_search: GoogleIcon,
google_sheets_v2: GoogleSheetsIcon,
google_slides_v2: GoogleSlidesIcon,
google_translate: GoogleTranslateIcon,
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,

View File

@@ -97,6 +97,7 @@ Understanding these core principles will help you build better workflows:
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
5. **State Persistence**: All block outputs and execution details are preserved for debugging
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
## Next Steps

View File

@@ -326,6 +326,8 @@ Get details about a specific version of a Confluence page.
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `pageId` | string | ID of the page |
| `title` | string | Page title at this version |
| `content` | string | Page content with HTML tags stripped at this version |
| `version` | object | Detailed version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
@@ -336,6 +338,9 @@ Get details about a specific version of a Confluence page.
| ↳ `collaborators` | array | List of collaborator account IDs for this version |
| ↳ `prevVersion` | number | Previous version number |
| ↳ `nextVersion` | number | Next version number |
| `body` | object | Raw page body content in storage format at this version |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
### `confluence_list_page_properties`

View File

@@ -0,0 +1,60 @@
---
title: Google Translate
description: Translate text using Google Cloud Translation
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_translate"
color="#E0E0E0"
/>
## Usage Instructions
Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language.
## Tools
### `google_translate_text`
Translate text between languages using the Google Cloud Translation API. Supports auto-detection of the source language.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to translate |
| `target` | string | Yes | Target language code \(e.g., "es", "fr", "de", "ja"\) |
| `source` | string | No | Source language code. If omitted, the API will auto-detect the source language. |
| `format` | string | No | Format of the text: "text" for plain text, "html" for HTML content |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `translatedText` | string | The translated text |
| `detectedSourceLanguage` | string | The detected source language code \(if source was not specified\) |
### `google_translate_detect`
Detect the language of text using the Google Cloud Translation API.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to detect the language of |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `language` | string | The detected language code \(e.g., "en", "es", "fr"\) |
| `confidence` | number | Confidence score of the detection |

View File

@@ -47,6 +47,7 @@
"google_search",
"google_sheets",
"google_slides",
"google_translate",
"google_vault",
"grafana",
"grain",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,218 @@
import Link from 'next/link'
import { Badge } from '@/components/emcn'
interface PricingTier {
id: string
name: string
description: string
price: string
billingPeriod?: string
color: string
features: string[]
cta: { label: string; href: string }
}
const PRICING_TIERS: PricingTier[] = [
{
id: 'community',
name: 'Community',
description: 'For individuals getting started with AI agents',
price: 'Free',
color: '#2ABBF8',
features: [
'$20 usage limit',
'5GB file storage',
'5 min execution limit',
'Limited log retention',
'CLI/SDK Access',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'professional',
name: 'Professional',
description: 'For professionals building production workflows',
price: '$20',
billingPeriod: 'per month',
color: '#00F701',
features: [
'150 runs per minute (sync)',
'1,000 runs per minute (async)',
'50 min sync execution limit',
'50GB file storage',
'Unlimited invites',
'Unlimited log retention',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'team',
name: 'Team',
description: 'For teams collaborating on complex agents',
price: '$40',
billingPeriod: 'per month',
color: '#FA4EDF',
features: [
'300 runs per minute (sync)',
'2,500 runs per minute (async)',
'500GB file storage (pooled)',
'50 min sync execution limit',
'Unlimited invites',
'Unlimited log retention',
'Dedicated Slack channel',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'For organizations needing security and scale',
price: 'Custom',
color: '#FFCC02',
features: ['Custom rate limits', 'Custom file storage', 'SSO', 'SOC2', 'Dedicated support'],
cta: { label: 'Book a demo', href: '/contact' },
},
]
function CheckIcon({ color }: { color: string }) {
return (
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
<path
d='M2.5 7L5.5 10L11.5 4'
stroke={color}
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
interface PricingCardProps {
tier: PricingTier
}
function PricingCard({ tier }: PricingCardProps) {
const isEnterprise = tier.id === 'enterprise'
const isProfessional = tier.id === 'professional'
return (
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[#E5E5E5] border-b-0 bg-white p-5'>
<div className='flex flex-col'>
<h3
id={`${tier.id}-heading`}
className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[100%] tracking-[-0.02em]'
>
{tier.name}
</h3>
<p className='mt-2 min-h-[44px] font-[430] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
{tier.description}
</p>
<p className='mt-4 flex items-center gap-1.5 font-[430] font-season text-[#1C1C1C] text-[20px] leading-[100%] tracking-[-0.02em]'>
{tier.price}
{tier.billingPeriod && (
<span className='text-[#737373] text-[16px]'>{tier.billingPeriod}</span>
)}
</p>
<div className='mt-4'>
{isEnterprise ? (
<a
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</a>
) : isProfessional ? (
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
>
{tier.cta.label}
</Link>
) : (
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</Link>
)}
</div>
</div>
<ul className='flex flex-col gap-2'>
{tier.features.map((feature) => (
<li key={feature} className='flex items-center gap-2'>
<CheckIcon color='#404040' />
<span className='font-[400] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
{feature}
</span>
</li>
))}
</ul>
</div>
<div className='relative h-[6px]'>
<div
className='absolute inset-0 rounded-b-sm opacity-60'
style={{ backgroundColor: tier.color }}
/>
<div
className='absolute top-0 right-0 bottom-0 left-[12%] rounded-b-sm opacity-60'
style={{ backgroundColor: tier.color }}
/>
<div
className='absolute top-0 right-0 bottom-0 left-[25%] rounded-b-sm'
style={{ backgroundColor: tier.color }}
/>
</div>
</article>
)
}
/**
* Pricing section — tiered pricing plans with feature comparison.
*
* SEO:
* - `<section id="pricing" aria-labelledby="pricing-heading">`.
* - `<h2 id="pricing-heading">` for the section title.
* - Each tier: `<h3>` plan name + semantic `<ul>` feature list.
* - Free tier CTA uses `<Link href="/signup">` (crawlable). Enterprise CTA uses `<a>`.
*
* GEO:
* - Each plan has consistent structure: name, price, billing period, feature list.
* - Lead with a summary: "Sim offers a free Community plan, $20/mo Pro, $40/mo Team, custom Enterprise."
* - Prices must match the `Offer` items in structured-data.tsx exactly.
*/
export default function Pricing() {
return (
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
<Badge
variant='blue'
size='md'
dot
className='bg-[#2ABBF8]/10 font-season text-[#2ABBF8] uppercase tracking-[0.02em]'
>
Pricing
</Badge>
<h2
id='pricing-heading'
className='font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
>
Pricing
</h2>
</div>
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
{PRICING_TIERS.map((tier) => (
<PricingCard key={tier.id} tier={tier} />
))}
</div>
</div>
</section>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. 60,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 60,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 60,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.',
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
},
},
{
@@ -203,7 +204,7 @@ export default function StructuredData() {
name: 'Do I need coding skills to use Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'No coding skills are required! Sim features a visual drag-and-drop interface that makes it easy to build AI workflows. However, developers can also use custom functions and our API for advanced use cases.',
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
},
},
],

View File

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

View File

@@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types'
@@ -178,8 +179,14 @@ export const POST = withMcpAuth('read')(
'sync'
)
const simViaHeader = request.headers.get(SIM_VIA_HEADER)
const extraHeaders: Record<string, string> = {}
if (simViaHeader) {
extraHeaders[SIM_VIA_HEADER] = simViaHeader
}
const result = await Promise.race([
mcpService.executeTool(userId, serverId, toolCall, workspaceId),
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout)
),

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { cleanHtmlContent, getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageVersionsAPI')
@@ -55,42 +55,79 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// If versionNumber is provided, get specific version
// If versionNumber is provided, get specific version with page content
if (versionNumber !== undefined && versionNumber !== null) {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}`
const versionUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}`
const pageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?version=${versionNumber}&body-format=storage`
logger.info(`Fetching version ${versionNumber} for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
const [versionResponse, pageResponse] = await Promise.all([
fetch(versionUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
fetch(pageUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
])
if (!response.ok) {
const errorData = await response.json().catch(() => null)
if (!versionResponse.ok) {
const errorData = await versionResponse.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
status: versionResponse.status,
statusText: versionResponse.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get page version (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
const errorMessage =
errorData?.message || `Failed to get page version (${versionResponse.status})`
return NextResponse.json({ error: errorMessage }, { status: versionResponse.status })
}
const data = await response.json()
const versionData = await versionResponse.json()
let title: string | null = null
let content: string | null = null
let body: Record<string, unknown> | null = null
if (pageResponse.ok) {
const pageData = await pageResponse.json()
title = pageData.title ?? null
body = pageData.body ?? null
const rawContent =
pageData.body?.storage?.value ||
pageData.body?.view?.value ||
pageData.body?.atlas_doc_format?.value ||
''
if (rawContent) {
content = cleanHtmlContent(rawContent)
}
} else {
logger.warn(
`Could not fetch page content for version ${versionNumber}: ${pageResponse.status}`
)
}
return NextResponse.json({
version: {
number: data.number,
message: data.message ?? null,
minorEdit: data.minorEdit ?? false,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
number: versionData.number,
message: versionData.message ?? null,
minorEdit: versionData.minorEdit ?? false,
authorId: versionData.authorId ?? null,
createdAt: versionData.createdAt ?? null,
},
pageId,
title,
content,
body,
})
}
// List all versions

View File

@@ -0,0 +1,44 @@
/**
* GET /api/v1/admin/audit-logs/[id]
*
* Get a single audit log entry by ID.
*
* Response: AdminSingleResponse<AdminAuditLog>
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminAuditLog } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminAuditLogDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id } = await context.params
try {
const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1)
if (!log) {
return notFoundResponse('AuditLog')
}
logger.info(`Admin API: Retrieved audit log ${id}`)
return singleResponse(toAdminAuditLog(log))
} catch (error) {
logger.error('Admin API: Failed to get audit log', { error, id })
return internalErrorResponse('Failed to get audit log')
}
})

View File

@@ -0,0 +1,96 @@
/**
* GET /api/v1/admin/audit-logs
*
* List all audit logs with pagination and filtering.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID
* - actorEmail: string (optional) - Filter by actor email
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
*
* Response: AdminListResponse<AdminAuditLog>
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminAuditLog,
createPaginationMeta,
parsePaginationParams,
toAdminAuditLog,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminAuditLogsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const actionFilter = url.searchParams.get('action')
const resourceTypeFilter = url.searchParams.get('resourceType')
const resourceIdFilter = url.searchParams.get('resourceId')
const workspaceIdFilter = url.searchParams.get('workspaceId')
const actorIdFilter = url.searchParams.get('actorId')
const actorEmailFilter = url.searchParams.get('actorEmail')
const startDateFilter = url.searchParams.get('startDate')
const endDateFilter = url.searchParams.get('endDate')
if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) {
return badRequestResponse('Invalid startDate format. Use ISO 8601.')
}
if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) {
return badRequestResponse('Invalid endDate format. Use ISO 8601.')
}
try {
const conditions: SQL<unknown>[] = []
if (actionFilter) conditions.push(eq(auditLog.action, actionFilter))
if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter))
if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter))
if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter))
if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter))
if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter))
if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter)))
if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter)))
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [countResult, logs] = await Promise.all([
db.select({ total: count() }).from(auditLog).where(whereClause),
db
.select()
.from(auditLog)
.where(whereClause)
.orderBy(desc(auditLog.createdAt))
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminAuditLog[] = logs.map(toAdminAuditLog)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list audit logs', { error })
return internalErrorResponse('Failed to list audit logs')
}
})

View File

@@ -6,6 +6,7 @@
*/
import type {
auditLog,
member,
organization,
referralCampaigns,
@@ -694,3 +695,45 @@ export function toAdminReferralCampaign(
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}
// =============================================================================
// Audit Log Types
// =============================================================================
export type DbAuditLog = InferSelectModel<typeof auditLog>
export interface AdminAuditLog {
id: string
workspaceId: string | null
actorId: string | null
actorName: string | null
actorEmail: string | null
action: string
resourceType: string
resourceId: string | null
resourceName: string | null
description: string | null
metadata: unknown
ipAddress: string | null
userAgent: string | null
createdAt: string
}
export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog {
return {
id: dbLog.id,
workspaceId: dbLog.workspaceId,
actorId: dbLog.actorId,
actorName: dbLog.actorName,
actorEmail: dbLog.actorEmail,
action: dbLog.action,
resourceType: dbLog.resourceType,
resourceId: dbLog.resourceId,
resourceName: dbLog.resourceName,
description: dbLog.description,
metadata: dbLog.metadata,
ipAddress: dbLog.ipAddress,
userAgent: dbLog.userAgent,
createdAt: dbLog.createdAt.toISOString(),
}
}

View File

@@ -0,0 +1,78 @@
/**
* GET /api/v1/audit-logs/[id]
*
* Get a single audit log entry by ID, scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Scope includes logs from current org members AND logs within org workspaces
* (including those from departed members or system actions with null actorId).
*
* Response: { data: AuditLogEntry, limits: UserLimits }
*/
import { db } from '@sim/db'
import { auditLog, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogDetailAPI')
export const revalidate = 0
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { id } = await params
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { orgMemberIds } = authResult.context
const orgWorkspaceIds = db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.ownerId, orgMemberIds))
const [log] = await db
.select()
.from(auditLog)
.where(
and(
eq(auditLog.id, id),
or(
inArray(auditLog.actorId, orgMemberIds),
inArray(auditLog.workspaceId, orgWorkspaceIds)
)
)
)
.limit(1)
if (!log) {
return NextResponse.json({ error: 'Audit log not found' }, { status: 404 })
}
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Audit log detail fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
/**
* Enterprise audit log authorization.
*
* Validates that the authenticated user is an admin/owner of an enterprise organization
* and returns the organization context needed for scoped queries.
*/
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
const logger = createLogger('V1AuditLogsAuth')
export interface EnterpriseAuditContext {
organizationId: string
orgMemberIds: string[]
}
type AuthResult =
| { success: true; context: EnterpriseAuditContext }
| { success: false; response: NextResponse }
/**
* Validates enterprise audit log access for the given user.
*
* Checks:
* 1. User belongs to an organization
* 2. User has admin or owner role
* 3. Organization has an active enterprise subscription
*
* Returns the organization ID and all member user IDs on success,
* or an error response on failure.
*/
export async function validateEnterpriseAuditAccess(userId: string): Promise<AuthResult> {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!membership) {
return {
success: false,
response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }),
}
}
if (membership.role !== 'admin' && membership.role !== 'owner') {
return {
success: false,
response: NextResponse.json(
{ error: 'Organization admin or owner role required' },
{ status: 403 }
),
}
}
const [orgSub, orgMembers] = await Promise.all([
db
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, membership.organizationId),
eq(subscription.plan, 'enterprise'),
eq(subscription.status, 'active')
)
)
.limit(1),
db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, membership.organizationId)),
])
if (orgSub.length === 0) {
return {
success: false,
response: NextResponse.json(
{ error: 'Active enterprise subscription required' },
{ status: 403 }
),
}
}
const orgMemberIds = orgMembers.map((m) => m.userId)
logger.info('Enterprise audit access validated', {
userId,
organizationId: membership.organizationId,
memberCount: orgMemberIds.length,
})
return {
success: true,
context: {
organizationId: membership.organizationId,
orgMemberIds,
},
}
}

View File

@@ -0,0 +1,43 @@
/**
* Enterprise audit log response formatting.
*
* Defines the shape returned by the enterprise audit log API.
* Excludes `ipAddress` and `userAgent` for privacy.
*/
import type { auditLog } from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
type DbAuditLog = InferSelectModel<typeof auditLog>
export interface EnterpriseAuditLogEntry {
id: string
workspaceId: string | null
actorId: string | null
actorName: string | null
actorEmail: string | null
action: string
resourceType: string
resourceId: string | null
resourceName: string | null
description: string | null
metadata: unknown
createdAt: string
}
export function formatAuditLogEntry(log: DbAuditLog): EnterpriseAuditLogEntry {
return {
id: log.id,
workspaceId: log.workspaceId,
actorId: log.actorId,
actorName: log.actorName,
actorEmail: log.actorEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
resourceName: log.resourceName,
description: log.description,
metadata: log.metadata,
createdAt: log.createdAt.toISOString(),
}
}

View File

@@ -0,0 +1,191 @@
/**
* GET /api/v1/audit-logs
*
* List audit logs scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Query Parameters:
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID (must be an org member)
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
* - includeDeparted: boolean (optional, default: false) - Include logs from departed members
* - limit: number (optional, default: 50, max: 100)
* - cursor: string (optional) - Opaque cursor for pagination
*
* Response: { data: AuditLogEntry[], nextCursor?: string, limits: UserLimits }
*/
import { db } from '@sim/db'
import { auditLog, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, gte, inArray, lt, lte, or, type SQL } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), {
message: 'Invalid date format. Use ISO 8601.',
})
const QueryParamsSchema = z.object({
action: z.string().optional(),
resourceType: z.string().optional(),
resourceId: z.string().optional(),
workspaceId: z.string().optional(),
actorId: z.string().optional(),
startDate: isoDateString.optional(),
endDate: isoDateString.optional(),
includeDeparted: z
.enum(['true', 'false'])
.transform((val) => val === 'true')
.optional()
.default('false'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
cursor: z.string().optional(),
})
interface CursorData {
createdAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { orgMemberIds } = authResult.context
const { searchParams } = new URL(request.url)
const rawParams = Object.fromEntries(searchParams.entries())
const validationResult = QueryParamsSchema.safeParse(rawParams)
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: validationResult.error.errors },
{ status: 400 }
)
}
const params = validationResult.data
if (params.actorId && !orgMemberIds.includes(params.actorId)) {
return NextResponse.json(
{ error: 'actorId is not a member of your organization' },
{ status: 400 }
)
}
let scopeCondition: SQL<unknown>
if (params.includeDeparted) {
const orgWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.ownerId, orgMemberIds))
const orgWorkspaceIds = orgWorkspaces.map((w) => w.id)
if (orgWorkspaceIds.length > 0) {
scopeCondition = or(
inArray(auditLog.actorId, orgMemberIds),
inArray(auditLog.workspaceId, orgWorkspaceIds)
)!
} else {
scopeCondition = inArray(auditLog.actorId, orgMemberIds)
}
} else {
scopeCondition = inArray(auditLog.actorId, orgMemberIds)
}
const conditions: SQL<unknown>[] = [scopeCondition]
if (params.action) conditions.push(eq(auditLog.action, params.action))
if (params.resourceType) conditions.push(eq(auditLog.resourceType, params.resourceType))
if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId))
if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId))
if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId))
if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate)))
if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate)))
if (params.cursor) {
const cursorData = decodeCursor(params.cursor)
if (cursorData?.createdAt && cursorData.id) {
const cursorDate = new Date(cursorData.createdAt)
if (!Number.isNaN(cursorDate.getTime())) {
conditions.push(
or(
lt(auditLog.createdAt, cursorDate),
and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id))
)!
)
}
}
}
const rows = await db
.select()
.from(auditLog)
.where(and(...conditions))
.orderBy(desc(auditLog.createdAt), desc(auditLog.id))
.limit(params.limit + 1)
const hasMore = rows.length > params.limit
const data = rows.slice(0, params.limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const last = data[data.length - 1]
nextCursor = encodeCursor({
createdAt: last.createdAt.toISOString(),
id: last.id,
})
}
const formattedLogs = data.map(formatAuditLogEntry)
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formattedLogs, nextCursor }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Audit logs fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -19,7 +19,7 @@ export interface RateLimitResult {
export async function checkRateLimit(
request: NextRequest,
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' | 'audit-logs' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

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

View File

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

View File

@@ -5,10 +5,10 @@ export default function manifest(): MetadataRoute.Manifest {
const brand = getBrandConfig()
return {
name: brand.name === 'Sim' ? 'Sim - AI Agent Workflow Builder' : brand.name,
name: brand.name === 'Sim' ? 'Sim — Build AI Agents & Run Your Agentic Workforce' : brand.name,
short_name: brand.name,
description:
'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. SOC2 and HIPAA compliant.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
start_url: '/',
scope: '/',
display: 'standalone',

View File

@@ -1,16 +1,18 @@
import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Landing from '@/app/(landing)/landing'
import Landing from '@/app/(home)/landing'
export const dynamic = 'force-dynamic'
const baseUrl = getBaseUrl()
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: 'Sim - AI Agent Workflow Builder | Open Source Platform',
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Open-source AI agent workflow builder used by 60,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 60,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. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
images: {
url: '/logo/426-240/primary/small.png',
alt: 'Sim - AI Agent Workflow Builder',
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
},
},
alternates: {
@@ -72,11 +74,12 @@ export const metadata: Metadata = {
classification: 'AI Development Tools',
referrer: 'origin-when-cross-origin',
other: {
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
'llm:content-type':
'AI agent platform, agentic workforce, agentic workflows, LLM orchestration',
'llm:use-cases':
'email automation, Slack bots, Discord moderation, data analysis, customer support, content generation, agentic automations',
'AI agents, agentic workforce, agentic workflows, knowledge bases, tables, document creation, email automation, Slack bots, data analysis, customer support, content generation',
'llm:integrations':
'OpenAI, Anthropic, Google AI, Slack, Gmail, Discord, Notion, Airtable, Supabase',
'OpenAI, Anthropic, Google AI, Mistral, xAI, Perplexity, Slack, Gmail, Discord, Notion, Airtable, Supabase',
'llm:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
'llm:region': 'global',
'llm:languages': 'en',

View File

@@ -21,6 +21,7 @@ interface TemplateCardProps {
blocks?: string[]
className?: string
state?: WorkflowState
description?: string | null
isStarred?: boolean
isVerified?: boolean
}
@@ -124,6 +125,7 @@ function TemplateCardInner({
blocks = [],
className,
state,
description,
isStarred = false,
isVerified = false,
}: TemplateCardProps) {
@@ -270,6 +272,12 @@ function TemplateCardInner({
</div>
</div>
{description && (
<p className='mt-[4px] truncate pl-[2px] text-[12px] text-[var(--text-tertiary)]'>
{description}
</p>
)}
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 items-center gap-[8px]'>
{authorImageUrl ? (

View File

@@ -196,6 +196,7 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}

View File

@@ -18,6 +18,7 @@ interface TemplateCardProps {
blocks?: string[]
className?: string
state?: WorkflowState
description?: string | null
isStarred?: boolean
isVerified?: boolean
}
@@ -127,6 +128,7 @@ function TemplateCardInner({
blocks = [],
className,
state,
description,
isStarred = false,
isVerified = false,
}: TemplateCardProps) {
@@ -277,6 +279,12 @@ function TemplateCardInner({
</div>
</div>
{description && (
<p className='mt-[4px] truncate pl-[2px] text-[12px] text-[var(--text-tertiary)]'>
{description}
</p>
)}
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
{authorImageUrl ? (

View File

@@ -222,6 +222,7 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}

View File

@@ -26,16 +26,21 @@ export interface CanvasMenuProps {
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onToggleWorkflowLock?: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
disableEdit?: boolean
disableAdmin?: boolean
canAdmin?: boolean
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
/** Whether the workflow has locked blocks (disables auto-layout) */
hasLockedBlocks?: boolean
/** Whether all blocks in the workflow are locked */
allBlocksLocked?: boolean
/** Whether the workflow has any blocks */
hasBlocks?: boolean
}
/**
@@ -56,13 +61,17 @@ export function CanvasMenu({
onOpenLogs,
onToggleVariables,
onToggleChat,
onToggleWorkflowLock,
isVariablesOpen = false,
isChatOpen = false,
hasClipboard = false,
disableEdit = false,
canAdmin = false,
canUndo = false,
canRedo = false,
hasLockedBlocks = false,
allBlocksLocked = false,
hasBlocks = false,
}: CanvasMenuProps) {
return (
<Popover
@@ -142,6 +151,17 @@ export function CanvasMenu({
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
{canAdmin && onToggleWorkflowLock && (
<PopoverItem
disabled={!hasBlocks}
onClick={() => {
onToggleWorkflowLock()
onClose()
}}
>
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
<PopoverItem
onClick={() => {
onFitToView()

View File

@@ -61,6 +61,9 @@ export const Notifications = memo(function Notifications() {
case 'refresh':
window.location.reload()
break
case 'unlock-workflow':
window.dispatchEvent(new CustomEvent('unlock-workflow'))
break
default:
logger.warn('Unknown action type', { notificationId, actionType: action.type })
}
@@ -175,7 +178,9 @@ export const Notifications = memo(function Notifications() {
? 'Fix in Copilot'
: notification.action!.type === 'refresh'
? 'Refresh'
: 'Take action'}
: notification.action!.type === 'unlock-workflow'
? 'Unlock Workflow'
: 'Take action'}
</Button>
)}
</div>

View File

@@ -2,7 +2,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowUp, Square } from 'lucide-react'
import { ArrowUp, Lock, Square, Unlock } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import {
@@ -42,7 +42,9 @@ import {
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
@@ -126,6 +128,15 @@ export const Panel = memo(function Panel() {
Object.values(state.blocks).some((block) => block.locked)
)
const allBlocksLocked = useWorkflowStore((state) => {
const blockList = Object.values(state.blocks)
return blockList.length > 0 && blockList.every((block) => block.locked)
})
const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0)
const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow()
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
@@ -329,6 +340,17 @@ export const Panel = memo(function Panel() {
workspaceId,
])
/**
* Toggles the locked state of all blocks in the workflow
*/
const handleToggleWorkflowLock = useCallback(() => {
const blocks = useWorkflowStore.getState().blocks
const allLocked = Object.values(blocks).every((b) => b.locked)
const ids = getWorkflowLockToggleIds(blocks, !allLocked)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
setIsMenuOpen(false)
}, [collaborativeBatchToggleLocked])
// Compute run button state
const canRun = userPermissions.canRead // Running only requires read permissions
const isLoadingPermissions = userPermissions.isLoading
@@ -399,6 +421,16 @@ export const Panel = memo(function Panel() {
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>
</PopoverItem>
{userPermissions.canAdmin && !currentWorkflow?.isSnapshotView && (
<PopoverItem onClick={handleToggleWorkflowLock} disabled={!hasBlocks}>
{allBlocksLocked ? (
<Unlock className='h-3 w-3' />
) : (
<Lock className='h-3 w-3' />
)}
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
{
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<VariableIcon className='h-3 w-3' />

View File

@@ -1298,7 +1298,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Content>
</Tooltip.Root>
)}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (

View File

@@ -71,3 +71,38 @@ export function filterProtectedBlocks(
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
}
}
/**
* Returns block IDs ordered so that `batchToggleLocked` will target the desired state.
*
* `batchToggleLocked` determines its target locked state from `!firstBlock.locked`.
* When `targetLocked` is true (lock all), an unlocked block must come first.
* When `targetLocked` is false (unlock all), a locked block must come first.
*
* Returns an empty array when there are no blocks or all blocks already match `targetLocked`.
*
* @param blocks - Record of all blocks in the workflow
* @param targetLocked - The desired locked state for all blocks
* @returns Sorted block IDs, or empty array if no toggle is needed
*/
export function getWorkflowLockToggleIds(
blocks: Record<string, BlockState>,
targetLocked: boolean
): string[] {
const ids = Object.keys(blocks)
if (ids.length === 0) return []
// No-op if all blocks already match the desired state
const allMatch = Object.values(blocks).every((b) => Boolean(b.locked) === targetLocked)
if (allMatch) return []
ids.sort((a, b) => {
const aVal = blocks[a].locked ? 1 : 0
const bVal = blocks[b].locked ? 1 : 0
// To lock all (targetLocked=true): unlocked first (aVal - bVal)
// To unlock all (targetLocked=false): locked first (bVal - aVal)
return targetLocked ? aVal - bVal : bVal - aVal
})
return ids
}

View File

@@ -57,6 +57,7 @@ import {
estimateBlockDimensions,
filterProtectedBlocks,
getClampedPositionForNode,
getWorkflowLockToggleIds,
isBlockProtected,
isEdgeProtected,
isInEditableElement,
@@ -393,6 +394,15 @@ const WorkflowContent = React.memo(() => {
const { blocks, edges, lastSaved } = currentWorkflow
const allBlocksLocked = useMemo(() => {
const blockList = Object.values(blocks)
return blockList.length > 0 && blockList.every((b) => b.locked)
}, [blocks])
const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks])
const hasLockedBlocks = useMemo(() => Object.values(blocks).some((b) => b.locked), [blocks])
const isWorkflowReady = useMemo(
() =>
hydration.phase === 'ready' &&
@@ -1175,6 +1185,91 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleLocked(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
const handleToggleWorkflowLock = useCallback(() => {
const currentBlocks = useWorkflowStore.getState().blocks
const allLocked = Object.values(currentBlocks).every((b) => b.locked)
const ids = getWorkflowLockToggleIds(currentBlocks, !allLocked)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
}, [collaborativeBatchToggleLocked])
// Show notification when all blocks in the workflow are locked
const lockNotificationIdRef = useRef<string | null>(null)
const clearLockNotification = useCallback(() => {
if (lockNotificationIdRef.current) {
useNotificationStore.getState().removeNotification(lockNotificationIdRef.current)
lockNotificationIdRef.current = null
}
}, [])
// Clear persisted lock notifications on mount/workflow change (prevents duplicates after reload)
useEffect(() => {
// Reset ref so the main effect creates a fresh notification for the new workflow
clearLockNotification()
if (!activeWorkflowId) return
const store = useNotificationStore.getState()
const stale = store.notifications.filter(
(n) =>
n.workflowId === activeWorkflowId &&
(n.action?.type === 'unlock-workflow' || n.message.startsWith('This workflow is locked'))
)
for (const n of stale) {
store.removeNotification(n.id)
}
}, [activeWorkflowId, clearLockNotification])
const prevCanAdminRef = useRef(effectivePermissions.canAdmin)
useEffect(() => {
if (!isWorkflowReady) return
const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin
prevCanAdminRef.current = effectivePermissions.canAdmin
// Clear stale notification when admin status changes so it recreates with correct message
if (canAdminChanged) {
clearLockNotification()
}
if (allBlocksLocked) {
if (lockNotificationIdRef.current) return
const isAdmin = effectivePermissions.canAdmin
lockNotificationIdRef.current = addNotification({
level: 'info',
message: isAdmin
? 'This workflow is locked'
: 'This workflow is locked. Ask an admin to unlock it.',
workflowId: activeWorkflowId || undefined,
...(isAdmin ? { action: { type: 'unlock-workflow' as const, message: '' } } : {}),
})
} else {
clearLockNotification()
}
}, [
allBlocksLocked,
isWorkflowReady,
effectivePermissions.canAdmin,
addNotification,
activeWorkflowId,
clearLockNotification,
])
// Clean up notification on unmount
useEffect(() => clearLockNotification, [clearLockNotification])
// Listen for unlock-workflow events from notification action button
useEffect(() => {
const handleUnlockWorkflow = () => {
const currentBlocks = useWorkflowStore.getState().blocks
const ids = getWorkflowLockToggleIds(currentBlocks, false)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
}
window.addEventListener('unlock-workflow', handleUnlockWorkflow)
return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow)
}, [collaborativeBatchToggleLocked])
const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -3699,7 +3794,11 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit}
canUndo={canUndo}
canRedo={canRedo}
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
hasLockedBlocks={hasLockedBlocks}
onToggleWorkflowLock={handleToggleWorkflowLock}
allBlocksLocked={allBlocksLocked}
canAdmin={effectivePermissions.canAdmin}
hasBlocks={hasBlocks}
/>
</>
)}

View File

@@ -440,6 +440,36 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`,
placeholder: 'Describe the range (e.g., "first 50 rows" or "column A")...',
},
},
// Read Filter Fields (advanced mode only)
{
id: 'filterColumn',
title: 'Filter Column',
type: 'short-input',
placeholder: 'Column header name to filter on (e.g., Email, Status)',
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
},
{
id: 'filterValue',
title: 'Filter Value',
type: 'short-input',
placeholder: 'Value to match against',
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
},
{
id: 'filterMatchType',
title: 'Match Type',
type: 'dropdown',
options: [
{ label: 'Contains', id: 'contains' },
{ label: 'Exact Match', id: 'exact' },
{ label: 'Starts With', id: 'starts_with' },
{ label: 'Ends With', id: 'ends_with' },
],
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
},
// Write-specific Fields
{
id: 'values',
@@ -748,6 +778,9 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
batchData,
sheetId,
destinationSpreadsheetId,
filterColumn,
filterValue,
filterMatchType,
...rest
} = params
@@ -836,6 +869,11 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
cellRange: cellRange ? (cellRange as string).trim() : undefined,
values: parsedValues,
oauthCredential,
...(filterColumn ? { filterColumn: (filterColumn as string).trim() } : {}),
...(filterValue !== undefined && filterValue !== ''
? { filterValue: filterValue as string }
: {}),
...(filterMatchType ? { filterMatchType: filterMatchType as string } : {}),
}
},
},
@@ -858,6 +896,12 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
type: 'string',
description: 'Destination spreadsheet ID for copy',
},
filterColumn: { type: 'string', description: 'Column header name to filter on' },
filterValue: { type: 'string', description: 'Value to match against the filter column' },
filterMatchType: {
type: 'string',
description: 'Match type: contains, exact, starts_with, or ends_with',
},
},
outputs: {
// Read outputs

View File

@@ -0,0 +1,215 @@
import { GoogleTranslateIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
const SUPPORTED_LANGUAGES = [
{ label: 'Afrikaans', id: 'af' },
{ label: 'Albanian', id: 'sq' },
{ label: 'Amharic', id: 'am' },
{ label: 'Arabic', id: 'ar' },
{ label: 'Armenian', id: 'hy' },
{ label: 'Assamese', id: 'as' },
{ label: 'Aymara', id: 'ay' },
{ label: 'Azerbaijani', id: 'az' },
{ label: 'Bambara', id: 'bm' },
{ label: 'Basque', id: 'eu' },
{ label: 'Belarusian', id: 'be' },
{ label: 'Bengali', id: 'bn' },
{ label: 'Bhojpuri', id: 'bho' },
{ label: 'Bosnian', id: 'bs' },
{ label: 'Bulgarian', id: 'bg' },
{ label: 'Catalan', id: 'ca' },
{ label: 'Cebuano', id: 'ceb' },
{ label: 'Chinese (Simplified)', id: 'zh-CN' },
{ label: 'Chinese (Traditional)', id: 'zh-TW' },
{ label: 'Corsican', id: 'co' },
{ label: 'Croatian', id: 'hr' },
{ label: 'Czech', id: 'cs' },
{ label: 'Danish', id: 'da' },
{ label: 'Dhivehi', id: 'dv' },
{ label: 'Dogri', id: 'doi' },
{ label: 'Dutch', id: 'nl' },
{ label: 'English', id: 'en' },
{ label: 'Esperanto', id: 'eo' },
{ label: 'Estonian', id: 'et' },
{ label: 'Ewe', id: 'ee' },
{ label: 'Filipino', id: 'tl' },
{ label: 'Finnish', id: 'fi' },
{ label: 'French', id: 'fr' },
{ label: 'Frisian', id: 'fy' },
{ label: 'Galician', id: 'gl' },
{ label: 'Georgian', id: 'ka' },
{ label: 'German', id: 'de' },
{ label: 'Greek', id: 'el' },
{ label: 'Guarani', id: 'gn' },
{ label: 'Gujarati', id: 'gu' },
{ label: 'Haitian Creole', id: 'ht' },
{ label: 'Hausa', id: 'ha' },
{ label: 'Hawaiian', id: 'haw' },
{ label: 'Hebrew', id: 'he' },
{ label: 'Hindi', id: 'hi' },
{ label: 'Hmong', id: 'hmn' },
{ label: 'Hungarian', id: 'hu' },
{ label: 'Icelandic', id: 'is' },
{ label: 'Igbo', id: 'ig' },
{ label: 'Ilocano', id: 'ilo' },
{ label: 'Indonesian', id: 'id' },
{ label: 'Irish', id: 'ga' },
{ label: 'Italian', id: 'it' },
{ label: 'Japanese', id: 'ja' },
{ label: 'Javanese', id: 'jv' },
{ label: 'Kannada', id: 'kn' },
{ label: 'Kazakh', id: 'kk' },
{ label: 'Khmer', id: 'km' },
{ label: 'Kinyarwanda', id: 'rw' },
{ label: 'Konkani', id: 'gom' },
{ label: 'Korean', id: 'ko' },
{ label: 'Krio', id: 'kri' },
{ label: 'Kurdish', id: 'ku' },
{ label: 'Kurdish (Sorani)', id: 'ckb' },
{ label: 'Kyrgyz', id: 'ky' },
{ label: 'Lao', id: 'lo' },
{ label: 'Latin', id: 'la' },
{ label: 'Latvian', id: 'lv' },
{ label: 'Lingala', id: 'ln' },
{ label: 'Lithuanian', id: 'lt' },
{ label: 'Luganda', id: 'lg' },
{ label: 'Luxembourgish', id: 'lb' },
{ label: 'Macedonian', id: 'mk' },
{ label: 'Maithili', id: 'mai' },
{ label: 'Malagasy', id: 'mg' },
{ label: 'Malay', id: 'ms' },
{ label: 'Malayalam', id: 'ml' },
{ label: 'Maltese', id: 'mt' },
{ label: 'Maori', id: 'mi' },
{ label: 'Marathi', id: 'mr' },
{ label: 'Meiteilon (Manipuri)', id: 'mni-Mtei' },
{ label: 'Mizo', id: 'lus' },
{ label: 'Mongolian', id: 'mn' },
{ label: 'Myanmar (Burmese)', id: 'my' },
{ label: 'Nepali', id: 'ne' },
{ label: 'Norwegian', id: 'no' },
{ label: 'Nyanja (Chichewa)', id: 'ny' },
{ label: 'Odia (Oriya)', id: 'or' },
{ label: 'Oromo', id: 'om' },
{ label: 'Pashto', id: 'ps' },
{ label: 'Persian', id: 'fa' },
{ label: 'Polish', id: 'pl' },
{ label: 'Portuguese', id: 'pt' },
{ label: 'Punjabi', id: 'pa' },
{ label: 'Quechua', id: 'qu' },
{ label: 'Romanian', id: 'ro' },
{ label: 'Russian', id: 'ru' },
{ label: 'Samoan', id: 'sm' },
{ label: 'Sanskrit', id: 'sa' },
{ label: 'Scots Gaelic', id: 'gd' },
{ label: 'Sepedi', id: 'nso' },
{ label: 'Serbian', id: 'sr' },
{ label: 'Sesotho', id: 'st' },
{ label: 'Shona', id: 'sn' },
{ label: 'Sindhi', id: 'sd' },
{ label: 'Sinhala', id: 'si' },
{ label: 'Slovak', id: 'sk' },
{ label: 'Slovenian', id: 'sl' },
{ label: 'Somali', id: 'so' },
{ label: 'Spanish', id: 'es' },
{ label: 'Sundanese', id: 'su' },
{ label: 'Swahili', id: 'sw' },
{ label: 'Swedish', id: 'sv' },
{ label: 'Tajik', id: 'tg' },
{ label: 'Tamil', id: 'ta' },
{ label: 'Tatar', id: 'tt' },
{ label: 'Telugu', id: 'te' },
{ label: 'Thai', id: 'th' },
{ label: 'Tigrinya', id: 'ti' },
{ label: 'Tsonga', id: 'ts' },
{ label: 'Turkish', id: 'tr' },
{ label: 'Turkmen', id: 'tk' },
{ label: 'Twi (Akan)', id: 'ak' },
{ label: 'Ukrainian', id: 'uk' },
{ label: 'Urdu', id: 'ur' },
{ label: 'Uyghur', id: 'ug' },
{ label: 'Uzbek', id: 'uz' },
{ label: 'Vietnamese', id: 'vi' },
{ label: 'Welsh', id: 'cy' },
{ label: 'Xhosa', id: 'xh' },
{ label: 'Yiddish', id: 'yi' },
{ label: 'Yoruba', id: 'yo' },
{ label: 'Zulu', id: 'zu' },
] as const
export const GoogleTranslateBlock: BlockConfig = {
type: 'google_translate',
name: 'Google Translate',
description: 'Translate text using Google Cloud Translation',
longDescription:
'Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language.',
docsLink: 'https://docs.sim.ai/tools/google_translate',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleTranslateIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Translate Text', id: 'text' },
{ label: 'Detect Language', id: 'detect' },
],
value: () => 'text',
},
{
id: 'text',
title: 'Text',
type: 'long-input',
placeholder: 'Enter text...',
required: true,
},
{
id: 'target',
title: 'Target Language',
type: 'dropdown',
condition: { field: 'operation', value: 'text' },
searchable: true,
options: SUPPORTED_LANGUAGES,
value: () => 'es',
required: { field: 'operation', value: 'text' },
},
{
id: 'source',
title: 'Source Language',
type: 'dropdown',
condition: { field: 'operation', value: 'text' },
searchable: true,
options: [{ label: 'Auto-detect', id: '' }, ...SUPPORTED_LANGUAGES],
value: () => '',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Google Cloud API key',
password: true,
required: true,
},
],
tools: {
access: ['google_translate_text', 'google_translate_detect'],
config: {
tool: (params) => `google_translate_${params.operation}`,
},
},
inputs: {
text: { type: 'string', description: 'Text to translate or detect language of' },
target: { type: 'string', description: 'Target language code' },
source: { type: 'string', description: 'Source language code (optional, auto-detected)' },
apiKey: { type: 'string', description: 'Google Cloud API key' },
},
outputs: {
translatedText: { type: 'string', description: 'Translated text' },
detectedSourceLanguage: { type: 'string', description: 'Detected source language code' },
language: { type: 'string', description: 'Detected language code' },
confidence: { type: 'number', description: 'Detection confidence score' },
},
}

View File

@@ -52,6 +52,7 @@ import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups'
import { GoogleMapsBlock } from '@/blocks/blocks/google_maps'
import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets'
import { GoogleSlidesBlock, GoogleSlidesV2Block } from '@/blocks/blocks/google_slides'
import { GoogleTranslateBlock } from '@/blocks/blocks/google_translate'
import { GoogleVaultBlock } from '@/blocks/blocks/google_vault'
import { GrafanaBlock } from '@/blocks/blocks/grafana'
import { GrainBlock } from '@/blocks/blocks/grain'
@@ -234,6 +235,7 @@ export const registry: Record<string, BlockConfig> = {
google_forms: GoogleFormsBlock,
google_groups: GoogleGroupsBlock,
google_maps: GoogleMapsBlock,
google_translate: GoogleTranslateBlock,
gong: GongBlock,
google_search: GoogleSearchBlock,
google_sheets: GoogleSheetsBlock,

View File

@@ -15,10 +15,12 @@ export function ChevronDown(props: SVGProps<SVGSVGElement>) {
{...props}
>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M5.47124 5.47133C5.34622 5.59631 5.17668 5.66652 4.9999 5.66652C4.82313 5.66652 4.65359 5.59631 4.52857 5.47133L0.757237 1.69999C0.693563 1.6385 0.642775 1.56493 0.607836 1.4836C0.572896 1.40226 0.554505 1.31478 0.553736 1.22626C0.552967 1.13774 0.569835 1.04996 0.603355 0.968026C0.636876 0.886095 0.686378 0.81166 0.748973 0.749064C0.811568 0.686469 0.886003 0.636967 0.967934 0.603447C1.04986 0.569926 1.13765 0.553058 1.22617 0.553828C1.31469 0.554597 1.40217 0.572988 1.48351 0.607927C1.56484 0.642866 1.63841 0.693654 1.6999 0.757328L4.9999 4.05733L8.2999 0.757328C8.42564 0.635889 8.59404 0.568693 8.76884 0.570212C8.94363 0.571731 9.11084 0.641843 9.23445 0.765449C9.35805 0.889054 9.42817 1.05626 9.42969 1.23106C9.43121 1.40586 9.36401 1.57426 9.24257 1.69999L5.47124 5.47133Z'
fill='currentColor'
d='M1 1L5 5L9 1'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
)

View File

@@ -537,6 +537,34 @@ export function GithubIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GithubOutlineIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M15 21C15 21 15 18.73 15 18C15 17.37 15.15 16.04 14.5 15.5C15.89 15.37 16.98 14.92 18 14C19.02 13.08 19.5 11.69 19.5 9.5C19.5 8 19.25 7 18.5 6C18.79 5.22 18.84 4 18.5 3C16.94 3 15.53 4.07 15 4.5C14.61 4.4 13.67 4 12 4C10.33 4 9.39 4.4 9 4.5C8.47 4.07 7.06 3 5.5 3C5.16 4 5.21 5.22 5.5 6C4.75 7 4.5 8 4.5 9.5C4.5 11.69 4.98 13.08 6 14C7.02 14.92 8.11 15.37 9.5 15.5C8.85 16.04 9 17.37 9 18C9 18.73 9 21 9 21'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M9 19C7.59 19 6.16 18.44 5.31 17.81C4.47 17.18 4.22 16.15 3 15.5'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
@@ -5445,6 +5473,34 @@ export function GoogleMapsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTranslateIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 998.1 998.3'>
<path
fill='#DBDBDB'
d='M931.7 998.3c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4H283.6l260.1 797.9h388z'
/>
<path
fill='#DCDCDC'
d='M931.7 230.4c9.7 0 18.9 3.8 25.8 10.6 6.8 6.7 10.6 15.5 10.6 24.8v667.1c0 9.3-3.7 18.1-10.6 24.8-6.9 6.8-16.1 10.6-25.8 10.6H565.5L324.9 230.4h606.8m0-30H283.6l260.1 797.9h388c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4z'
/>
<polygon fill='#4352B8' points='482.3,809.8 543.7,998.3 714.4,809.8' />
<path
fill='#607988'
d='M936.1 476.1V437H747.6v-63.2h-61.2V437H566.1v39.1h239.4c-12.8 45.1-41.1 87.7-68.7 120.8-48.9-57.9-49.1-76.7-49.1-76.7h-50.8s2.1 28.2 70.7 108.6c-22.3 22.8-39.2 36.3-39.2 36.3l15.6 48.8s23.6-20.3 53.1-51.6c29.6 32.1 67.8 70.7 117.2 116.7l32.1-32.1c-52.9-48-91.7-86.1-120.2-116.7 38.2-45.2 77-102.1 85.2-154.2H936v.1z'
/>
<path
fill='#4285F4'
d='M66.4 0C29.9 0 0 29.9 0 66.5v677c0 36.5 29.9 66.4 66.4 66.4h648.1L454.4 0h-388z'
/>
<path
fill='#EEEEEE'
d='M371.4 430.6c-2.5 30.3-28.4 75.2-91.1 75.2-54.3 0-98.3-44.9-98.3-100.2s44-100.2 98.3-100.2c30.9 0 51.5 13.4 63.3 24.3l41.2-39.6c-27.1-25-62.4-40.6-104.5-40.6-86.1 0-156 69.9-156 156s69.9 156 156 156c90.2 0 149.8-63.3 149.8-152.6 0-12.8-1.6-22.2-3.7-31.8h-146v53.4l91 .1z'
/>
</svg>
)
}
export function DsPyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='30 28 185 175' fill='none'>

View File

@@ -9,8 +9,8 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
const brand = getBrandConfig()
const defaultTitle = brand.name
const summaryFull = `Sim is an open-source AI agent workflow builder. Developers at trail-blazing startups to Fortune 500 companies deploy agentic workflows on the Sim platform. 60,000+ developers already use Sim to build and deploy AI agent workflows and connect them to 100+ apps. Sim is SOC2 and HIPAA compliant, ensuring enterprise-grade security for AI automation.`
const summaryShort = `Sim is an open-source AI agent workflow builder for production workflows.`
const summaryFull = `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 — from startups to Fortune 500 companies. SOC2 and HIPAA compliant.`
const summaryShort = `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.`
return {
title: {
@@ -22,20 +22,21 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
authors: [{ name: brand.name }],
generator: 'Next.js',
keywords: [
'AI agent',
'AI agent builder',
'AI agent workflow',
'AI workflow automation',
'visual workflow editor',
'AI agents',
'workflow canvas',
'agentic workforce',
'AI agent platform',
'open-source AI agents',
'agentic workflows',
'LLM orchestration',
'AI integrations',
'knowledge base',
'AI automation',
'workflow builder',
'AI workflow orchestration',
'enterprise AI',
'AI agent deployment',
'intelligent automation',
'AI tools',
'workflow designer',
'artificial intelligence',
'business automation',
'AI agent workflows',
'visual programming',
],
referrer: 'origin-when-cross-origin',
creator: brand.name,
@@ -130,11 +131,11 @@ export function generateStructuredData() {
'@type': 'SoftwareApplication',
name: 'Sim',
description:
'Sim is an open-source AI agent workflow builder. Developers at trail-blazing startups to Fortune 500 companies deploy agentic workflows on the Sim platform. 60,000+ developers already use Sim to build and deploy AI agent workflows and connect them to 100+ apps. Sim is SOC2 and HIPAA compliant, ensuring enterprise-level security.',
'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.',
url: getBaseUrl(),
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
applicationSubCategory: 'AIWorkflowAutomation',
operatingSystem: 'Web',
applicationSubCategory: 'AIAgentPlatform',
areaServed: 'Worldwide',
availableLanguage: ['en'],
offers: {
@@ -147,10 +148,13 @@ export function generateStructuredData() {
url: 'https://sim.ai',
},
featureList: [
'Visual AI Agent Builder',
'Workflow Canvas Interface',
'AI Agent Automation',
'Custom AI Workflows',
'AI Agent Creation',
'Agentic Workflow Orchestration',
'1,000+ Integrations',
'LLM Orchestration',
'Knowledge Base Creation',
'Table Creation',
'Document Creation',
],
}
}

View File

@@ -158,7 +158,6 @@ export const DEFAULTS = {
MAX_LOOP_ITERATIONS: 1000,
MAX_FOREACH_ITEMS: 1000,
MAX_PARALLEL_BRANCHES: 20,
MAX_WORKFLOW_DEPTH: 10,
MAX_SSE_CHILD_DEPTH: 3,
EXECUTION_TIME: 0,
TOKENS: {

View File

@@ -123,7 +123,6 @@ describe('AgentBlockHandler', () => {
let handler: AgentBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let originalPromiseAll: any
beforeEach(() => {
handler = new AgentBlockHandler()
@@ -135,8 +134,6 @@ describe('AgentBlockHandler', () => {
configurable: true,
})
originalPromiseAll = Promise.all
mockBlock = {
id: 'test-agent-block',
metadata: { id: BlockType.AGENT, name: 'Test Agent' },
@@ -209,8 +206,6 @@ describe('AgentBlockHandler', () => {
})
afterEach(() => {
Promise.all = originalPromiseAll
try {
Object.defineProperty(global, 'window', {
value: undefined,
@@ -271,38 +266,7 @@ describe('AgentBlockHandler', () => {
expect(result).toEqual(expectedOutput)
})
it('should preserve executeFunction for custom tools with different usageControl settings', async () => {
let capturedTools: any[] = []
Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
const result = originalPromiseAll.call(Promise, promises)
result.then((tools: any[]) => {
if (tools?.length) {
capturedTools = tools.filter((t) => t !== null)
}
})
return result
})
mockExecuteProviderRequest.mockResolvedValueOnce({
content: 'Using tools to respond',
model: 'mock-model',
tokens: { input: 10, output: 20, total: 30 },
toolCalls: [
{
name: 'auto_tool',
arguments: { input: 'test input for auto tool' },
},
{
name: 'force_tool',
arguments: { input: 'test input for force tool' },
},
],
timing: { total: 100 },
})
it('should preserve usageControl for custom tools and filter out "none"', async () => {
const inputs = {
model: 'gpt-4o',
userPrompt: 'Test custom tools with different usageControl settings',
@@ -372,13 +336,14 @@ describe('AgentBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
expect(Promise.all).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const tools = providerCall[1].tools
expect(capturedTools.length).toBe(2)
expect(tools.length).toBe(2)
const autoTool = capturedTools.find((t) => t.name === 'auto_tool')
const forceTool = capturedTools.find((t) => t.name === 'force_tool')
const noneTool = capturedTools.find((t) => t.name === 'none_tool')
const autoTool = tools.find((t: any) => t.name === 'auto_tool')
const forceTool = tools.find((t: any) => t.name === 'force_tool')
const noneTool = tools.find((t: any) => t.name === 'none_tool')
expect(autoTool).toBeDefined()
expect(forceTool).toBeDefined()
@@ -386,37 +351,6 @@ describe('AgentBlockHandler', () => {
expect(autoTool.usageControl).toBe('auto')
expect(forceTool.usageControl).toBe('force')
expect(typeof autoTool.executeFunction).toBe('function')
expect(typeof forceTool.executeFunction).toBe('function')
await autoTool.executeFunction({ input: 'test input' })
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: 'return { result: "auto tool executed", input }',
input: 'test input',
}),
false, // skipPostProcess
expect.any(Object) // execution context
)
await forceTool.executeFunction({ input: 'another test' })
expect(mockExecuteTool).toHaveBeenNthCalledWith(
2, // Check the 2nd call
'function_execute',
expect.objectContaining({
code: 'return { result: "force tool executed", input }',
input: 'another test',
}),
false, // skipPostProcess
expect.any(Object) // execution context
)
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const requestBody = providerCall[1]
expect(requestBody.tools.length).toBe(2)
})
it('should filter out tools with usageControl set to "none"', async () => {
@@ -1763,6 +1697,52 @@ describe('AgentBlockHandler', () => {
expect(providerCallArgs[1].tools[0].name).toBe('search_files')
})
it('should pass callChain to executeProviderRequest for MCP cycle detection', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
)
const inputs = {
model: 'gpt-4o',
userPrompt: 'Search for files',
apiKey: 'test-api-key',
tools: [
{
type: 'mcp',
title: 'search_files',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
params: {
serverId: 'mcp-search-server',
toolName: 'search_files',
serverName: 'search',
},
usageControl: 'auto' as const,
},
],
}
const contextWithCallChain = {
...mockContext,
workspaceId: 'test-workspace-123',
workflowId: 'test-workflow-456',
callChain: ['wf-parent', 'test-workflow-456'],
}
mockGetProviderFromModel.mockReturnValue('openai')
await handler.execute(contextWithCallChain, mockBlock, inputs)
expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1]
expect(providerCallArgs.callChain).toEqual(['wf-parent', 'test-workflow-456'])
})
it('should handle multiple MCP tools from the same server efficiently', async () => {
const fetchCalls: any[] = []
@@ -2139,21 +2119,10 @@ describe('AgentBlockHandler', () => {
expect(tools.length).toBe(0)
})
it('should use DB code for executeFunction when customToolId resolves', async () => {
it('should use DB schema when customToolId resolves', async () => {
const toolId = 'custom-tool-123'
mockFetchForCustomTool(toolId)
let capturedTools: any[] = []
Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
const result = originalPromiseAll.call(Promise, promises)
result.then((tools: any[]) => {
if (tools?.length) {
capturedTools = tools.filter((t) => t !== null)
}
})
return result
})
const inputs = {
model: 'gpt-4o',
userPrompt: 'Format a report',
@@ -2174,19 +2143,12 @@ describe('AgentBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
expect(capturedTools.length).toBe(1)
expect(typeof capturedTools[0].executeFunction).toBe('function')
expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const tools = providerCall[1].tools
await capturedTools[0].executeFunction({ title: 'Q1', format: 'pdf' })
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: dbCode,
}),
false,
expect.any(Object)
)
expect(tools.length).toBe(1)
expect(tools[0].name).toBe('formatReport')
})
it('should not fetch from DB when no customToolId is present', async () => {

View File

@@ -1,9 +1,8 @@
import { db } from '@sim/db'
import { account, mcpServers } from '@sim/db/schema'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import {
@@ -30,10 +29,10 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool, getToolAsync } from '@/tools/utils'
const logger = createLogger('AgentBlockHandler')
@@ -276,14 +275,12 @@ export class AgentBlockHandler implements BlockHandler {
const userProvidedParams = tool.params || {}
let schema = tool.schema
let code = tool.code
let title = tool.title
if (tool.customToolId) {
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
if (resolved) {
schema = resolved.schema
code = resolved.code
title = resolved.title
} else if (!schema) {
logger.error(`Custom tool not found: ${tool.customToolId}`)
@@ -296,7 +293,7 @@ export class AgentBlockHandler implements BlockHandler {
return null
}
const { filterSchemaForLLM, mergeToolParameters } = await import('@/tools/params')
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams)
@@ -313,43 +310,6 @@ export class AgentBlockHandler implements BlockHandler {
usageControl: tool.usageControl || 'auto',
}
if (code) {
base.executeFunction = async (callParams: Record<string, any>) => {
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
{
code,
...mergedParams,
timeout: tool.timeout ?? AGENT.DEFAULT_FUNCTION_TIMEOUT,
envVars: ctx.environmentVariables || {},
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
blockOutputSchemas,
isCustomTool: true,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
},
false,
ctx
)
if (!result.success) {
throw new Error(result.error || 'Function execution failed')
}
return result.output
}
}
return base
}
@@ -359,7 +319,7 @@ export class AgentBlockHandler implements BlockHandler {
private async fetchCustomToolById(
ctx: ExecutionContext,
customToolId: string
): Promise<{ schema: any; code: string; title: string } | null> {
): Promise<{ schema: any; title: string } | null> {
if (typeof window !== 'undefined') {
try {
const { getCustomTool } = await import('@/hooks/queries/custom-tools')
@@ -367,7 +327,6 @@ export class AgentBlockHandler implements BlockHandler {
if (tool) {
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
}
@@ -416,7 +375,6 @@ export class AgentBlockHandler implements BlockHandler {
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
} catch (error) {
@@ -481,65 +439,15 @@ export class AgentBlockHandler implements BlockHandler {
tool: ToolInput
): Promise<any> {
const { serverId, toolName, serverName, ...userProvidedParams } = tool.params || {}
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(
tool.schema || { type: 'object', properties: {} },
userProvidedParams
)
const toolId = createMcpToolId(serverId, toolName)
return {
id: toolId,
name: toolName,
return this.buildMcpTool({
serverId,
toolName,
description:
tool.schema?.description || `MCP tool ${toolName} from ${serverName || serverId}`,
parameters: filteredSchema,
params: userProvidedParams,
usageControl: tool.usageControl || 'auto',
executeFunction: async (callParams: Record<string, any>) => {
const headers = await buildAuthHeaders()
const execParams: Record<string, string> = {}
if (ctx.userId) execParams.userId = ctx.userId
const execUrl = buildAPIUrl('/api/mcp/tools/execute', execParams)
const execResponse = await fetch(execUrl.toString(), {
method: 'POST',
headers,
body: stringifyJSON({
serverId,
toolName,
arguments: callParams,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
toolSchema: tool.schema,
}),
})
if (!execResponse.ok) {
throw new Error(
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
)
}
const result = await execResponse.json()
if (!result.success) {
throw new Error(result.error || 'MCP tool execution failed')
}
return {
success: true,
output: result.data.output || {},
metadata: {
source: 'mcp',
serverId,
serverName: serverName || serverId,
toolName,
},
}
},
}
schema: tool.schema || { type: 'object', properties: {} },
userProvidedParams,
usageControl: tool.usageControl,
})
}
/**
@@ -668,63 +576,35 @@ export class AgentBlockHandler implements BlockHandler {
serverId: string
): Promise<any> {
const { toolName, ...userProvidedParams } = tool.params || {}
return this.buildMcpTool({
serverId,
toolName,
description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`,
schema: mcpTool.inputSchema || { type: 'object', properties: {} },
userProvidedParams,
usageControl: tool.usageControl,
})
}
private async buildMcpTool(config: {
serverId: string
toolName: string
description: string
schema: any
userProvidedParams: Record<string, any>
usageControl?: string
}): Promise<any> {
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(
mcpTool.inputSchema || { type: 'object', properties: {} },
userProvidedParams
)
const toolId = createMcpToolId(serverId, toolName)
const filteredSchema = filterSchemaForLLM(config.schema, config.userProvidedParams)
const toolId = createMcpToolId(config.serverId, config.toolName)
return {
id: toolId,
name: toolName,
description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`,
name: config.toolName,
description: config.description,
parameters: filteredSchema,
params: userProvidedParams,
usageControl: tool.usageControl || 'auto',
executeFunction: async (callParams: Record<string, any>) => {
const headers = await buildAuthHeaders()
const discoverExecParams: Record<string, string> = {}
if (ctx.userId) discoverExecParams.userId = ctx.userId
const execUrl = buildAPIUrl('/api/mcp/tools/execute', discoverExecParams)
const execResponse = await fetch(execUrl.toString(), {
method: 'POST',
headers,
body: stringifyJSON({
serverId,
toolName,
arguments: callParams,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
toolSchema: mcpTool.inputSchema,
}),
})
if (!execResponse.ok) {
throw new Error(
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
)
}
const result = await execResponse.json()
if (!result.success) {
throw new Error(result.error || 'MCP tool execution failed')
}
return {
success: true,
output: result.data.output || {},
metadata: {
source: 'mcp',
serverId,
serverName: mcpTool.serverName,
toolName,
},
}
},
params: config.userProvidedParams,
usageControl: config.usageControl || 'auto',
}
}
@@ -1048,9 +928,9 @@ export class AgentBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = providerRequest.apiKey
if (providerId === 'vertex' && providerRequest.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(
finalApiKey = await resolveVertexCredential(
providerRequest.vertexCredential,
ctx.workflowId
'vertex-agent'
)
}
@@ -1082,6 +962,7 @@ export class AgentBlockHandler implements BlockHandler {
blockData,
blockNameMapping,
isDeployedContext: ctx.isDeployedContext,
callChain: ctx.callChain,
reasoningEffort: providerRequest.reasoningEffort,
verbosity: providerRequest.verbosity,
thinkingLevel: providerRequest.thinkingLevel,
@@ -1096,37 +977,6 @@ export class AgentBlockHandler implements BlockHandler {
}
}
/**
* Resolves a Vertex AI OAuth credential to an access token
*/
private async resolveVertexCredential(credentialId: string, workflowId: string): Promise<string> {
const requestId = `vertex-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
}
private handleExecutionError(
error: any,
startTime: number,
@@ -1310,7 +1160,7 @@ export class AgentBlockHandler implements BlockHandler {
},
toolCalls: {
list: result.toolCalls?.map(this.formatToolCall.bind(this)) || [],
count: result.toolCalls?.length || DEFAULTS.EXECUTION_TIME,
count: result.toolCalls?.length ?? 0,
},
providerTiming: result.timing,
cost: result.cost,

View File

@@ -1,14 +1,11 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
@@ -44,7 +41,10 @@ export class EvaluatorBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = evaluatorConfig.apiKey
if (providerId === 'vertex' && evaluatorConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(evaluatorConfig.vertexCredential)
finalApiKey = await resolveVertexCredential(
evaluatorConfig.vertexCredential,
'vertex-evaluator'
)
}
const processedContent = this.processContent(inputs.content)
@@ -234,7 +234,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
if (Object.keys(parsedContent).length === 0) {
validMetrics.forEach((metric: any) => {
if (metric?.name) {
metricScores[metric.name.toLowerCase()] = DEFAULTS.EXECUTION_TIME
metricScores[metric.name.toLowerCase()] = 0
}
})
return metricScores
@@ -273,37 +273,6 @@ export class EvaluatorBlockHandler implements BlockHandler {
}
logger.warn(`Metric "${metricName}" not found in LLM response`)
return DEFAULTS.EXECUTION_TIME
}
/**
* Resolves a Vertex AI OAuth credential to an access token
*/
private async resolveVertexCredential(credentialId: string): Promise<string> {
const requestId = `vertex-evaluator-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
return 0
}
}

View File

@@ -9,7 +9,6 @@ import {
HTTP,
normalizeName,
PAUSE_RESUME,
REFERENCE,
} from '@/executor/constants'
import {
generatePauseContextId,
@@ -17,6 +16,7 @@ import {
} from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { convertBuilderDataToJson, convertPropertyValue } from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -265,7 +265,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
@@ -296,7 +296,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
}
}
const value = this.convertPropertyValue(prop)
const value = convertPropertyValue(prop)
entries.push({
name: path,
@@ -352,140 +352,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
.filter((field): field is NormalizedInputField => field !== null)
}
private convertBuilderDataToJson(builderData: JSONProperty[]): any {
if (!Array.isArray(builderData)) {
return {}
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
const value = this.convertPropertyValue(prop)
result[prop.name] = value
}
return result
}
static convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
if (!Array.isArray(builderData) || builderData.length === 0) {
return '{\n \n}'
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
result[prop.name] = prop.value
}
let jsonString = JSON.stringify(result, null, 2)
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
return jsonString
}
private convertPropertyValue(prop: JSONProperty): any {
switch (prop.type) {
case 'object':
return this.convertObjectValue(prop.value)
case 'array':
return this.convertArrayValue(prop.value)
case 'number':
return this.convertNumberValue(prop.value)
case 'boolean':
return this.convertBooleanValue(prop.value)
case 'files':
return prop.value
default:
return prop.value
}
}
private convertObjectValue(value: any): any {
if (Array.isArray(value)) {
return this.convertBuilderDataToJson(value)
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
return this.tryParseJson(value, value)
}
return value
}
private convertArrayValue(value: any): any {
if (Array.isArray(value)) {
return value.map((item: any) => this.convertArrayItem(item))
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
const parsed = this.tryParseJson(value, value)
return Array.isArray(parsed) ? parsed : value
}
return value
}
private convertArrayItem(item: any): any {
if (typeof item !== 'object' || !item.type) {
return item
}
if (item.type === 'object' && Array.isArray(item.value)) {
return this.convertBuilderDataToJson(item.value)
}
if (item.type === 'array' && Array.isArray(item.value)) {
return item.value.map((subItem: any) =>
typeof subItem === 'object' && subItem.type ? subItem.value : subItem
)
}
return item.value
}
private convertNumberValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
const numValue = Number(value)
return Number.isNaN(numValue) ? value : numValue
}
private convertBooleanValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
return value === 'true' || value === true
}
private tryParseJson(jsonString: string, fallback: any): any {
try {
return JSON.parse(jsonString)
} catch {
return fallback
}
}
private isVariableReference(value: any): boolean {
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)

View File

@@ -1,19 +1,15 @@
import { createLogger } from '@sim/logger'
import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import {
convertBuilderDataToJson,
convertBuilderDataToJsonString,
} from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ResponseBlockHandler')
interface JSONProperty {
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
value: any
collapsed?: boolean
}
export class ResponseBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.RESPONSE
@@ -73,154 +69,15 @@ export class ResponseBlockHandler implements BlockHandler {
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
}
private convertBuilderDataToJson(builderData: JSONProperty[]): any {
if (!Array.isArray(builderData)) {
return {}
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
const value = this.convertPropertyValue(prop)
result[prop.name] = value
}
return result
}
static convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
if (!Array.isArray(builderData) || builderData.length === 0) {
return '{\n \n}'
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
result[prop.name] = prop.value
}
let jsonString = JSON.stringify(result, null, 2)
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
return jsonString
}
private convertPropertyValue(prop: JSONProperty): any {
switch (prop.type) {
case 'object':
return this.convertObjectValue(prop.value)
case 'array':
return this.convertArrayValue(prop.value)
case 'number':
return this.convertNumberValue(prop.value)
case 'boolean':
return this.convertBooleanValue(prop.value)
case 'files':
return prop.value
default:
return prop.value
}
}
private convertObjectValue(value: any): any {
if (Array.isArray(value)) {
return this.convertBuilderDataToJson(value)
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
return this.tryParseJson(value, value)
}
return value
}
private convertArrayValue(value: any): any {
if (Array.isArray(value)) {
return value.map((item: any) => this.convertArrayItem(item))
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
const parsed = this.tryParseJson(value, value)
if (Array.isArray(parsed)) {
return parsed
}
return value
}
return value
}
private convertArrayItem(item: any): any {
if (typeof item !== 'object' || !item.type) {
return item
}
if (item.type === 'object' && Array.isArray(item.value)) {
return this.convertBuilderDataToJson(item.value)
}
if (item.type === 'array' && Array.isArray(item.value)) {
return item.value.map((subItem: any) => {
if (typeof subItem === 'object' && subItem.type) {
return subItem.value
}
return subItem
})
}
return item.value
}
private convertNumberValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
const numValue = Number(value)
if (Number.isNaN(numValue)) {
return value
}
return numValue
}
private convertBooleanValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
return value === 'true' || value === true
}
private tryParseJson(jsonString: string, fallback: any): any {
try {
return JSON.parse(jsonString)
} catch {
return fallback
}
}
private isVariableReference(value: any): boolean {
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
static convertBuilderDataToJsonString(builderData: any[]): string {
return convertBuilderDataToJsonString(builderData)
}
private parseStatus(status?: string): number {

View File

@@ -1,9 +1,5 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
@@ -16,6 +12,7 @@ import {
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
@@ -87,7 +84,7 @@ export class RouterBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
finalApiKey = await resolveVertexCredential(routerConfig.vertexCredential, 'vertex-router')
}
const providerRequest: Record<string, any> = {
@@ -217,7 +214,7 @@ export class RouterBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
finalApiKey = await resolveVertexCredential(routerConfig.vertexCredential, 'vertex-router')
}
const providerRequest: Record<string, any> = {
@@ -416,35 +413,4 @@ export class RouterBlockHandler implements BlockHandler {
}
})
}
/**
* Resolves a Vertex AI OAuth credential to an access token
*/
private async resolveVertexCredential(credentialId: string): Promise<string> {
const requestId = `vertex-router-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
}
}

View File

@@ -108,18 +108,16 @@ describe('WorkflowBlockHandler', () => {
)
})
it('should enforce maximum depth limit', async () => {
it('should enforce maximum call chain depth limit', async () => {
const inputs = { workflowId: 'child-workflow-id' }
// Create a deeply nested context (simulate 11 levels deep to exceed the limit of 10)
const deepContext = {
...mockContext,
workflowId:
'level1_sub_level2_sub_level3_sub_level4_sub_level5_sub_level6_sub_level7_sub_level8_sub_level9_sub_level10_sub_level11',
callChain: Array.from({ length: 25 }, (_, i) => `wf-${i}`),
}
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
'"child-workflow-id" failed: Maximum workflow nesting depth of 10 exceeded'
'Maximum workflow call chain depth (25) exceeded'
)
})

View File

@@ -98,13 +98,17 @@ export class WorkflowBlockHandler implements BlockHandler {
// workflow block execution, preventing cross-iteration child mixing in loop contexts.
const instanceId = crypto.randomUUID()
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
let childWorkflowSnapshotId: string | undefined
try {
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
throw new Error(`Maximum workflow nesting depth of ${DEFAULTS.MAX_WORKFLOW_DEPTH} exceeded`)
}
if (ctx.isDeployedContext) {
const hasActiveDeployment = await this.checkChildDeployment(workflowId)
if (!hasActiveDeployment) {
@@ -126,7 +130,7 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
`Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}`
)
let childWorkflowInput: Record<string, any> = {}
@@ -168,15 +172,6 @@ export class WorkflowBlockHandler implements BlockHandler {
ctx.onChildWorkflowInstanceReady?.(effectiveBlockId, instanceId, iterationContext)
}
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
const subExecutor = new Executor({
workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput,
@@ -584,45 +579,6 @@ export class WorkflowBlockHandler implements BlockHandler {
return processed
}
private flattenChildWorkflowSpans(spans: TraceSpan[]): WorkflowTraceSpan[] {
const flattened: WorkflowTraceSpan[] = []
spans.forEach((span) => {
if (this.isSyntheticWorkflowWrapper(span)) {
if (span.children && Array.isArray(span.children)) {
flattened.push(...this.flattenChildWorkflowSpans(span.children))
}
return
}
const workflowSpan: WorkflowTraceSpan = {
...span,
}
if (Array.isArray(workflowSpan.children)) {
const childSpans = workflowSpan.children as TraceSpan[]
workflowSpan.children = this.flattenChildWorkflowSpans(childSpans)
}
if (workflowSpan.output && typeof workflowSpan.output === 'object') {
const { childTraceSpans: nestedChildSpans, ...outputRest } = workflowSpan.output as {
childTraceSpans?: TraceSpan[]
} & Record<string, unknown>
if (Array.isArray(nestedChildSpans) && nestedChildSpans.length > 0) {
const flattenedNestedChildren = this.flattenChildWorkflowSpans(nestedChildSpans)
workflowSpan.children = [...(workflowSpan.children || []), ...flattenedNestedChildren]
}
workflowSpan.output = outputRest
}
flattened.push(workflowSpan)
})
return flattened
}
private toExecutionResult(result: ExecutionResult | StreamingExecution): ExecutionResult {
return 'execution' in result ? result.execution : result
}

View File

@@ -0,0 +1,149 @@
import { REFERENCE } from '@/executor/constants'
export interface JSONProperty {
id: string
name: string
type: string
value: any
collapsed?: boolean
}
/**
* Converts builder data (structured JSON properties) into a plain JSON object.
*/
export function convertBuilderDataToJson(builderData: JSONProperty[]): any {
if (!Array.isArray(builderData)) {
return {}
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
const value = convertPropertyValue(prop)
result[prop.name] = value
}
return result
}
/**
* Converts builder data into a JSON string with variable references unquoted.
*/
export function convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
if (!Array.isArray(builderData) || builderData.length === 0) {
return '{\n \n}'
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
result[prop.name] = prop.value
}
let jsonString = JSON.stringify(result, null, 2)
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
return jsonString
}
export function convertPropertyValue(prop: JSONProperty): any {
switch (prop.type) {
case 'object':
return convertObjectValue(prop.value)
case 'array':
return convertArrayValue(prop.value)
case 'number':
return convertNumberValue(prop.value)
case 'boolean':
return convertBooleanValue(prop.value)
case 'files':
return prop.value
default:
return prop.value
}
}
function convertObjectValue(value: any): any {
if (Array.isArray(value)) {
return convertBuilderDataToJson(value)
}
if (typeof value === 'string' && !isVariableReference(value)) {
return tryParseJson(value, value)
}
return value
}
function convertArrayValue(value: any): any {
if (Array.isArray(value)) {
return value.map((item: any) => convertArrayItem(item))
}
if (typeof value === 'string' && !isVariableReference(value)) {
const parsed = tryParseJson(value, value)
return Array.isArray(parsed) ? parsed : value
}
return value
}
function convertArrayItem(item: any): any {
if (typeof item !== 'object' || !item.type) {
return item
}
if (item.type === 'object' && Array.isArray(item.value)) {
return convertBuilderDataToJson(item.value)
}
if (item.type === 'array' && Array.isArray(item.value)) {
return item.value.map((subItem: any) =>
typeof subItem === 'object' && subItem.type ? subItem.value : subItem
)
}
return item.value
}
function convertNumberValue(value: any): any {
if (isVariableReference(value)) {
return value
}
const numValue = Number(value)
return Number.isNaN(numValue) ? value : numValue
}
function convertBooleanValue(value: any): any {
if (isVariableReference(value)) {
return value
}
return value === 'true' || value === true
}
function tryParseJson(jsonString: string, fallback: any): any {
try {
return JSON.parse(jsonString)
} catch {
return fallback
}
}
function isVariableReference(value: any): boolean {
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
}

View File

@@ -0,0 +1,42 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('VertexCredential')
/**
* Resolves a Vertex AI OAuth credential to an access token.
* Shared across agent, evaluator, and router handlers.
*/
export async function resolveVertexCredential(
credentialId: string,
callerLabel = 'vertex'
): Promise<string> {
const requestId = `${callerLabel}-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
}

View File

@@ -131,6 +131,8 @@ export const AuditAction = {
WORKFLOW_DUPLICATED: 'workflow.duplicated',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated',
WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted',
WORKFLOW_LOCKED: 'workflow.locked',
WORKFLOW_UNLOCKED: 'workflow.unlocked',
WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated',
// Workspaces

View File

@@ -1,7 +1,7 @@
/**
* Environment utility functions for consistent environment detection across the application
*/
import { env, getEnv, isFalsy, isTruthy } from './env'
import { env, isFalsy, isTruthy } from './env'
/**
* Is the application running in production mode
@@ -21,9 +21,10 @@ export const isTest = env.NODE_ENV === 'test'
/**
* Is this the hosted version of the application
*/
export const isHosted =
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
// export const isHosted =
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
export const isHosted = true
/**
* Is billing enforcement enabled

View File

@@ -19,8 +19,8 @@ describe('call-chain', () => {
})
describe('MAX_CALL_CHAIN_DEPTH', () => {
it('equals 10', () => {
expect(MAX_CALL_CHAIN_DEPTH).toBe(10)
it('equals 25', () => {
expect(MAX_CALL_CHAIN_DEPTH).toBe(25)
})
})

View File

@@ -7,7 +7,7 @@
*/
export const SIM_VIA_HEADER = 'X-Sim-Via'
export const MAX_CALL_CHAIN_DEPTH = 10
export const MAX_CALL_CHAIN_DEPTH = 25
/**
* Parses the `X-Sim-Via` header value into an ordered list of workflow IDs.

View File

@@ -170,7 +170,8 @@ class McpService {
userId: string,
serverId: string,
toolCall: McpToolCall,
workspaceId: string
workspaceId: string,
extraHeaders?: Record<string, string>
): Promise<McpToolResult> {
const requestId = generateRequestId()
const maxRetries = 2
@@ -187,6 +188,9 @@ class McpService {
}
const resolvedConfig = await this.resolveConfigEnvVars(config, userId, workspaceId)
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders }
}
const client = await this.createClient(resolvedConfig)
try {

View File

@@ -174,6 +174,7 @@ export interface ProviderRequest {
verbosity?: string
thinkingLevel?: string
isDeployedContext?: boolean
callChain?: string[]
/** Previous interaction ID for multi-turn Interactions API requests (deep research follow-ups) */
previousInteractionId?: string
abortSignal?: AbortSignal

View File

@@ -1110,6 +1110,7 @@ export function prepareToolExecution(
blockData?: Record<string, any>
blockNameMapping?: Record<string, string>
isDeployedContext?: boolean
callChain?: string[]
}
): {
toolParams: Record<string, any>
@@ -1137,6 +1138,7 @@ export function prepareToolExecution(
...(request.isDeployedContext !== undefined
? { isDeployedContext: request.isDeployedContext }
: {}),
...(request.callChain ? { callChain: request.callChain } : {}),
},
}
: {}),

View File

@@ -0,0 +1,11 @@
<svg width="34" height="226" viewBox="0 0 34 226.021" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.6" width="16.8626" height="140.507" rx="2.59574" transform="matrix(-1 0 0 1 33.986 85.335)" fill="#00F701"/>
<rect opacity="0.6" width="16.8626" height="68.480" rx="2.59574" transform="matrix(-1 0 0 1 33.727 0)" fill="#FA4EDF"/>
<rect opacity="0.6" width="16.8626" height="33.986" rx="2.59574" transform="matrix(0 1 1 0 0 51.616)" fill="#FA4EDF"/>
<rect opacity="0.4" x="17.119" y="136.962" width="34.240" height="16.8626" rx="2.59574" transform="rotate(-90 17.119 136.962)" fill="#FFCC02"/>
<rect opacity="0.6" width="34.240" height="33.725" rx="2.59574" transform="matrix(0 1 1 0 0 0)" fill="#FA4EDF"/>
<rect opacity="0.5" width="34.240" height="33.725" rx="2.59574" transform="matrix(0 1 1 0 0.257 153.825)" fill="#00F701"/>
<rect width="16.8626" height="16.8626" rx="2.59574" transform="matrix(-1 0 0 1 33.727 17.378)" fill="#FA4EDF"/>
<rect x="17.119" y="136.962" width="16.8626" height="16.8626" rx="2.59574" transform="rotate(-90 17.119 136.962)" fill="#FFCC02"/>
<rect width="16.8626" height="16.8626" rx="2.59574" transform="matrix(0 1 1 0 0.257 153.825)" fill="#00F701"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="34" height="205" viewBox="0 0 34 204.769" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.6" width="16.8626" height="102.384" rx="2.59574" transform="matrix(-1 0 0 1 33.787 102.384)" fill="#2ABBF8"/>
<rect opacity="0.6" width="16.8626" height="68.482" rx="2.59574" transform="matrix(-1 0 0 1 33.739 16.888)" fill="#FA4EDF"/>
<rect opacity="0.6" width="16.8626" height="33.726" rx="2.59574" transform="matrix(0 1 1 0 0.012 68.510)" fill="#FA4EDF"/>
<rect opacity="0.6" width="16.8626" height="33.726" rx="2.59574" transform="matrix(0 1 1 0 0 33.776)" fill="#FA4EDF"/>
<rect opacity="0.6" width="16.8626" height="33.726" rx="2.59574" transform="matrix(0 1 1 0 0 0)" fill="#FA4EDF"/>
<rect opacity="0.4" x="17.131" y="153.859" width="34.241" height="16.8626" rx="2.59574" transform="rotate(-90 17.131 153.859)" fill="#00F701"/>
<rect opacity="0.6" width="34.241" height="16.8626" rx="2.59574" transform="matrix(0 1 1 0 16.891 0)" fill="#FA4EDF"/>
<rect width="16.8626" height="16.8626" rx="2.59574" transform="matrix(-1 0 0 1 33.739 34.272)" fill="#FA4EDF"/>
<rect x="17.131" y="153.859" width="16.8626" height="16.8626" rx="2.59574" transform="rotate(-90 17.131 153.859)" fill="#00F701"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,20 @@
<svg width="295" height="34" viewBox="0 0 295 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="16.8626" height="33.7252" rx="2.59574" fill="#2ABBF8"/>
<rect x="34.2403" y="0" width="34.2403" height="33.7252" rx="2.59574" opacity="0.6" fill="#2ABBF8"/>
<rect x="106.268" y="0" width="34.2403" height="33.7252" rx="2.59574" opacity="0.6" fill="#00F701"/>
<rect x="209.137" y="0" width="16.8626" height="33.7252" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
<rect x="243.233" y="0" width="34.2403" height="33.7252" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
<rect x="0" y="0" width="85.3433" height="16.8626" rx="2.59574" opacity="0.6" fill="#2ABBF8"/>
<rect x="68.4812" y="0" width="54.6502" height="16.8626" rx="2.59574" fill="#00F701"/>
<rect x="106.268" y="0" width="51.103" height="16.8626" rx="2.59574" opacity="0.6" fill="#00F701"/>
<rect x="157.371" y="0" width="34.2403" height="16.8626" rx="2.59574" opacity="0.6" fill="#FFCC02"/>
<rect x="208.993" y="0" width="68.4805" height="16.8626" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
<rect x="260.096" y="0" width="34.04" height="16.8626" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
<rect x="0" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#2ABBF8"/>
<rect x="34.2403" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#2ABBF8"/>
<rect x="157.371" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#FFCC02"/>
<rect x="243.233" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#FA4EDF"/>
<rect x="51.6188" y="16.8626" width="16.8626" height="16.8626" rx="2.59574" fill="#2ABBF8"/>
<rect x="123.6484" y="16.8626" width="16.8626" height="16.8626" rx="2.59574" fill="#00F701"/>
<rect x="260.611" y="16.8626" width="16.8626" height="16.8626" rx="2.59574" fill="#FA4EDF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="344" height="328" viewBox="0 0 344 328" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

View File

@@ -0,0 +1,3 @@
<svg width="471" height="470" viewBox="0 0 471 470" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,16 @@
<svg width="71" height="22" viewBox="0 0 71 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="paint0_linear_3547_13413" x1="171.406" y1="171.18" x2="245.831" y2="245.428" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
<g transform="scale(0.07483)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M142.793 124.175C142.793 128.925 140.913 133.487 137.577 136.846L137.099 137.327C133.765 140.696 129.236 142.579 124.519 142.579H17.8063C7.97854 142.579 0 150.605 0 160.503V275.91C0 285.808 7.97854 293.834 17.8063 293.834H132.383C142.211 293.834 150.179 285.808 150.179 275.91V167.858C150.179 163.453 151.914 159.226 155.009 156.109C158.095 153.001 162.292 151.253 166.666 151.253H275.166C284.994 151.253 292.962 143.229 292.962 133.33V17.9231C292.962 8.02512 284.994 0 275.166 0H160.588C150.761 0 142.793 8.02512 142.793 17.9231V124.175ZM177.564 24.5671H258.181C263.925 24.5671 268.57 29.2545 268.57 35.0301V116.224C268.57 121.998 263.925 126.687 258.181 126.687H177.564C171.83 126.687 167.175 121.998 167.175 116.224V35.0301C167.175 29.2545 171.83 24.5671 177.564 24.5671Z" fill="#33C482"/>
<path d="M275.293 171.578H190.106C179.779 171.578 171.406 180.01 171.406 190.412V275.162C171.406 285.564 179.779 293.996 190.106 293.996H275.293C285.621 293.996 293.994 285.564 293.994 275.162V190.412C293.994 180.01 285.621 171.578 275.293 171.578Z" fill="#33C482"/>
<path d="M275.293 171.18H190.106C179.779 171.18 171.406 179.612 171.406 190.014V274.763C171.406 285.165 179.779 293.596 190.106 293.596H275.293C285.621 293.596 293.994 285.165 293.994 274.763V190.014C293.994 179.612 285.621 171.18 275.293 171.18Z" fill="url(#paint0_linear_3547_13413)" fill-opacity="0.2"/>
</g>
<path d="M31.5718 15.845H34.1583C34.1583 16.5591 34.4169 17.1285 34.9342 17.5531C35.4515 17.9584 36.1508 18.1611 37.0321 18.1611C37.9901 18.1611 38.7277 17.9777 39.245 17.611C39.7623 17.225 40.021 16.7135 40.021 16.0766C40.021 15.6134 39.8773 15.2274 39.5899 14.9186C39.3217 14.6098 38.8235 14.3589 38.0955 14.1659L35.6239 13.5869C34.3786 13.2781 33.4494 12.8052 32.8363 12.1683C32.2423 11.5314 31.9454 10.6918 31.9454 9.64957C31.9454 8.78105 32.1657 8.02833 32.6064 7.39142C33.0662 6.7545 33.6889 6.26234 34.4744 5.91494C35.2791 5.56753 36.1987 5.39382 37.2333 5.39382C38.2679 5.39382 39.1588 5.57718 39.906 5.94389C40.6724 6.31059 41.2663 6.82206 41.6878 7.47827C42.1285 8.13449 42.3584 8.91615 42.3776 9.82327H39.7911C39.7719 9.08986 39.5324 8.52049 39.0726 8.11518C38.6128 7.70988 37.9709 7.50722 37.1471 7.50722C36.3041 7.50722 35.6527 7.69058 35.1929 8.05728C34.733 8.42399 34.5031 8.9258 34.5031 9.56272C34.5031 10.5084 35.1929 11.155 36.5723 11.5024L39.0439 12.1104C40.2317 12.3806 41.1226 12.8245 41.7166 13.4421C42.3105 14.0404 42.6075 14.8607 42.6075 15.9029C42.6075 16.7907 42.368 17.5724 41.889 18.2479C41.41 18.9041 40.749 19.4156 39.906 19.7823C39.0822 20.1297 38.1051 20.3034 36.9747 20.3034C35.327 20.3034 34.0146 19.8981 33.0375 19.0875C32.0603 18.2769 31.5718 17.196 31.5718 15.845Z" fill="#F6F6F0"/>
<path d="M44.5096 19.956V5.79913C45.5868 6.19296 46.0617 6.19296 47.211 5.79913V19.956H44.5096ZM45.8316 4.86332C45.3526 4.86332 44.9311 4.68962 44.5671 4.34221C44.2222 3.9755 44.0498 3.55089 44.0498 3.06838C44.0498 2.56657 44.2222 2.14196 44.5671 1.79455C44.9311 1.44714 45.3526 1.27344 45.8316 1.27344C46.3297 1.27344 46.7512 1.44714 47.0961 1.79455C47.441 2.14196 47.6134 2.56657 47.6134 3.06838C47.6134 3.55089 47.441 3.9755 47.0961 4.34221C46.7512 4.68962 46.3297 4.86332 45.8316 4.86332Z" fill="#F6F6F0"/>
<path d="M51.976 19.956H49.2746V5.79913H51.6887V8.18778C51.976 7.39647 52.5317 6.72555 53.298 6.20444C54.0835 5.66403 55.0319 5.39382 56.1432 5.39382C57.3885 5.39382 58.4231 5.73158 59.247 6.4071C60.0708 7.08261 60.6073 7.98008 60.8563 9.09951H60.3678C60.5594 7.98008 61.0862 7.08261 61.9484 6.4071C62.8106 5.73158 63.8739 5.39382 65.1384 5.39382C66.7478 5.39382 68.0123 5.86668 68.9319 6.8124C69.8516 7.75813 70.3114 9.05126 70.3114 10.6918V19.956H67.6674V11.3577C67.6674 10.2382 67.38 9.37936 66.8053 8.78105C66.2496 8.16344 65.4928 7.85463 64.5349 7.85463C63.8643 7.85463 63.2704 8.00903 62.7531 8.31784C62.2549 8.60735 61.8622 9.03196 61.5748 9.59167C61.2874 10.1514 61.1437 10.8076 61.1437 11.5603V19.956H58.471V11.3287C58.471 10.2093 58.1932 9.36006 57.6376 8.78105C57.082 8.18274 56.3252 7.88358 55.3672 7.88358C54.6966 7.88358 54.1027 8.03798 53.5854 8.34679C53.0873 8.6363 52.6945 9.06091 52.4071 9.62062C52.1197 10.161 51.976 10.8076 51.976 11.5603V19.956Z" fill="#F6F6F0"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray, or, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { env } from '@/lib/core/config/env'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -207,6 +208,17 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
}
})
// Audit workflow-level lock/unlock operations
if (
target === OPERATION_TARGETS.BLOCKS &&
op === BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED &&
userId
) {
auditWorkflowLockToggle(workflowId, userId).catch((error) => {
logger.error('Failed to audit workflow lock toggle', { error, workflowId })
})
}
const duration = Date.now() - startTime
if (duration > 100) {
logger.warn('Slow socket DB operation:', {
@@ -226,6 +238,45 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
}
}
/**
* Records an audit log entry when all blocks in a workflow are locked or unlocked.
* Only audits workflow-level transitions (all locked or all unlocked), not partial toggles.
*/
async function auditWorkflowLockToggle(workflowId: string, actorId: string): Promise<void> {
const [wf] = await db
.select({ name: workflow.name, workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
if (!wf) return
const blocks = await db
.select({ locked: workflowBlocks.locked })
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
if (blocks.length === 0) return
const allLocked = blocks.every((b) => b.locked)
const allUnlocked = blocks.every((b) => !b.locked)
// Only audit workflow-level transitions, not partial toggles
if (!allLocked && !allUnlocked) return
recordAudit({
workspaceId: wf.workspaceId,
actorId,
action: allLocked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: wf.name,
description: allLocked
? `Locked workflow "${wf.name}"`
: `Unlocked workflow "${wf.name}"`,
metadata: { blockCount: blocks.length },
})
}
async function handleBlockOperationTx(
tx: any,
workflowId: string,

View File

@@ -6,7 +6,7 @@ export interface NotificationAction {
/**
* Action type identifier for handler reconstruction
*/
type: 'copilot' | 'refresh'
type: 'copilot' | 'refresh' | 'unlock-workflow'
/**
* Message or data to pass to the action handler.

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