Compare commits

...

11 Commits

Author SHA1 Message Date
Emir Karabeg
a657d2f8ac feat(landing): navbar, metadata, hero, templates header 2026-02-17 19:51:52 -08:00
Emir Karabeg
03a218f8a0 feat(landing): structure 2026-02-17 19:51:52 -08:00
Waleed
6421b1a0ca feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist (#3240)
* feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist

* ack PR comments

* cleanup
2026-02-17 18:01:52 -08:00
Waleed
61a5c98717 fix(shortlink): use redirect instead of rewrite for Beluga tracking (#3239) 2026-02-17 16:27:20 -08:00
Waleed
a0afb5d03e feat(pipedrive): added sort order to endpoints that support it, upgraded turborepo (#3237)
* feat(pipedrive): added sort order to endpoints that support it

* upgraded turborepo

* fix
2026-02-17 14:58:54 -08:00
Waleed
cdacb796a8 improvement(providers): replace @ts-ignore with typed ProviderError class (#3235) 2026-02-17 14:20:31 -08:00
Waleed
3ce54147e6 fix(pagination): add missing next_page to response interfaces and operator comments (#3236) 2026-02-17 14:13:45 -08:00
Waleed
08690b2906 feat(pagination): update pagination for remaining integrations that support it (#3233)
* feat(pagination): update pagination for remaining integrations that support it

* fixed remaining

* ack comments
2026-02-17 13:34:46 -08:00
Waleed
299cc26694 improvement(lint): fix react-doctor errors and warnings (#3232)
* improvement(lint): fix react-doctor errors and warnings

* remove separators
2026-02-17 11:40:47 -08:00
Emir Karabeg
48715ff013 improvement(copilot): scrolling stickiness (#3218)
- Changed default stickinessThreshold from 100 to 30 in use-scroll-management.ts
- Removed explicit stickinessThreshold override (40) from copilot.tsx
- Both copilot and chat now use the same default value of 30
- This makes scrolling less sticky across all copilot message interactions

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
2026-02-17 10:33:10 -08:00
Waleed
ad0d0ed1f1 feat(shortlink): add Beluga short link rewrite for hosted campaigns (#3231) 2026-02-17 10:32:32 -08:00
134 changed files with 4003 additions and 891 deletions

View File

@@ -4,7 +4,7 @@
</a> </a>
</p> </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"> <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> <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 { return {
title: data.title, title: data.title,
description: 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: [ keywords: [
'AI workflow builder',
'visual workflow editor',
'AI automation',
'workflow automation',
'AI agents', 'AI agents',
'no-code AI', 'agentic workforce',
'drag and drop workflows', 'AI agent platform',
'agentic workflows',
'LLM orchestration',
'AI automation',
'knowledge base',
'AI integrations',
data.title?.toLowerCase().split(' '), data.title?.toLowerCase().split(' '),
] ]
.flat() .flat()
@@ -282,7 +284,8 @@ export async function generateMetadata(props: {
openGraph: { openGraph: {
title: data.title, title: data.title,
description: 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, url: fullUrl,
siteName: 'Sim Documentation', siteName: 'Sim Documentation',
type: 'article', type: 'article',
@@ -303,7 +306,8 @@ export async function generateMetadata(props: {
card: 'summary_large_image', card: 'summary_large_image',
title: data.title, title: data.title,
description: 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], images: [ogImageUrl],
creator: '@simdotai', creator: '@simdotai',
site: '@simdotai', site: '@simdotai',

View File

@@ -63,7 +63,7 @@ export default async function Layout({ children, params }: LayoutProps) {
'@type': 'WebSite', '@type': 'WebSite',
name: 'Sim Documentation', name: 'Sim Documentation',
description: 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', url: 'https://docs.sim.ai',
publisher: { publisher: {
'@type': 'Organization', '@type': 'Organization',

View File

@@ -59,12 +59,6 @@ body {
--content-gap: 1.75rem; --content-gap: 1.75rem;
} }
/* Remove custom layout variable overrides to fallback to fumadocs defaults */
/* ============================================
Navbar Light Mode Styling
============================================ */
/* Light mode navbar and search styling */ /* Light mode navbar and search styling */
:root:not(.dark) nav { :root:not(.dark) nav {
background-color: hsla(0, 0%, 96%, 0.85) !important; background-color: hsla(0, 0%, 96%, 0.85) !important;
@@ -88,10 +82,6 @@ body {
-webkit-backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important; -webkit-backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important;
} }
/* ============================================
Custom Sidebar Styling (Turborepo-inspired)
============================================ */
/* Floating sidebar appearance - remove background */ /* Floating sidebar appearance - remove background */
[data-sidebar-container], [data-sidebar-container],
#nd-sidebar { #nd-sidebar {
@@ -468,10 +458,6 @@ aside[data-sidebar],
writing-mode: horizontal-tb !important; writing-mode: horizontal-tb !important;
} }
/* ============================================
Code Block Styling (Improved)
============================================ */
/* Apply Geist Mono to code elements */ /* Apply Geist Mono to code elements */
code, code,
pre, pre,
@@ -532,10 +518,6 @@ pre code .line {
color: var(--color-fd-primary); color: var(--color-fd-primary);
} }
/* ============================================
TOC (Table of Contents) Styling
============================================ */
/* Remove the thin border-left on nested TOC items (keeps main indicator only) */ /* Remove the thin border-left on nested TOC items (keeps main indicator only) */
#nd-toc a[style*="padding-inline-start"] { #nd-toc a[style*="padding-inline-start"] {
border-left: none !important; border-left: none !important;
@@ -554,10 +536,6 @@ main article,
padding-bottom: 4rem; padding-bottom: 4rem;
} }
/* ============================================
Center and Constrain Main Content Width
============================================ */
/* Main content area - center and constrain like turborepo/raindrop */ /* Main content area - center and constrain like turborepo/raindrop */
/* Note: --sidebar-offset and --toc-offset are now applied at #nd-docs-layout level */ /* Note: --sidebar-offset and --toc-offset are now applied at #nd-docs-layout level */
main[data-main] { main[data-main] {

View File

@@ -7,26 +7,27 @@ export default function RootLayout({ children }: { children: ReactNode }) {
export const metadata = { export const metadata = {
metadataBase: new URL('https://docs.sim.ai'), metadataBase: new URL('https://docs.sim.ai'),
title: { title: {
default: 'Sim Documentation - Visual Workflow Builder for AI Applications', default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
template: '%s', template: '%s',
}, },
description: 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: [ keywords: [
'AI workflow builder',
'visual workflow editor',
'AI automation',
'workflow automation',
'AI agents', 'AI agents',
'no-code AI', 'agentic workforce',
'drag and drop workflows', 'AI agent platform',
'open-source AI agents',
'agentic workflows',
'LLM orchestration',
'AI integrations', 'AI integrations',
'workflow canvas', 'knowledge base',
'AI Agent Workflow Builder', 'AI automation',
'workflow orchestration', 'workflow builder',
'agent builder', 'AI workflow orchestration',
'AI workflow automation', 'enterprise AI',
'visual programming', 'AI agent deployment',
'intelligent automation',
'AI tools',
], ],
authors: [{ name: 'Sim Team', url: 'https://sim.ai' }], authors: [{ name: 'Sim Team', url: 'https://sim.ai' }],
creator: 'Sim', creator: 'Sim',
@@ -53,9 +54,9 @@ export const metadata = {
alternateLocale: ['es_ES', 'fr_FR', 'de_DE', 'ja_JP', 'zh_CN'], alternateLocale: ['es_ES', 'fr_FR', 'de_DE', 'ja_JP', 'zh_CN'],
url: 'https://docs.sim.ai', url: 'https://docs.sim.ai',
siteName: 'Sim Documentation', siteName: 'Sim Documentation',
title: 'Sim Documentation - Visual Workflow Builder for AI Applications', title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
description: 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: [ images: [
{ {
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation', url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation',
@@ -67,9 +68,9 @@ export const metadata = {
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
title: 'Sim Documentation - Visual Workflow Builder for AI Applications', title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
description: 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', creator: '@simdotai',
site: '@simdotai', site: '@simdotai',
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation'], images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation'],

View File

@@ -37,9 +37,9 @@ export async function GET() {
const manifest = `# Sim Documentation 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 ## Documentation Overview

View File

@@ -13,9 +13,9 @@ export function TOCFooter() {
<div className='text-balance font-semibold text-base leading-tight'> <div className='text-balance font-semibold text-base leading-tight'>
Start building today Start building today
</div> </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'> <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> </div>
<Link <Link
href='https://sim.ai/signup' href='https://sim.ai/signup'

View File

@@ -74,7 +74,7 @@ export function StructuredData({
name: 'Sim Documentation', name: 'Sim Documentation',
url: baseUrl, url: baseUrl,
description: 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: { publisher: {
'@type': 'Organization', '@type': 'Organization',
name: 'Sim', name: 'Sim',
@@ -104,7 +104,7 @@ export function StructuredData({
applicationCategory: 'DeveloperApplication', applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any', operatingSystem: 'Any',
description: 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, url: baseUrl,
author: { author: {
'@type': 'Organization', '@type': 'Organization',
@@ -115,12 +115,13 @@ export function StructuredData({
category: 'Developer Tools', category: 'Developer Tools',
}, },
featureList: [ featureList: [
'Visual workflow builder with drag-and-drop interface', 'AI agent creation',
'AI agent creation and automation', 'Agentic workflow orchestration',
'80+ built-in integrations', '1,000+ integrations',
'Real-time team collaboration', 'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Multiple deployment options', 'Knowledge base creation',
'Custom integrations via MCP protocol', 'Table creation',
'Document creation',
], ],
} }

View File

@@ -234,7 +234,6 @@ List actions from incident.io. Optionally filter by incident ID.
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | incident.io API Key | | `apiKey` | string | Yes | incident.io API Key |
| `incident_id` | string | No | Filter actions by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | | `incident_id` | string | No | Filter actions by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) |
| `page_size` | number | No | Number of actions to return per page \(e.g., 10, 25, 50\) |
#### Output #### Output
@@ -309,7 +308,6 @@ List follow-ups from incident.io. Optionally filter by incident ID.
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | incident.io API Key | | `apiKey` | string | Yes | incident.io API Key |
| `incident_id` | string | No | Filter follow-ups by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | | `incident_id` | string | No | Filter follow-ups by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) |
| `page_size` | number | No | Number of follow-ups to return per page \(e.g., 10, 25, 50\) |
#### Output #### Output
@@ -396,6 +394,7 @@ List all users in your Incident.io workspace. Returns user details including id,
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Incident.io API Key | | `apiKey` | string | Yes | Incident.io API Key |
| `page_size` | number | No | Number of results to return per page \(e.g., 10, 25, 50\). Default: 25 | | `page_size` | number | No | Number of results to return per page \(e.g., 10, 25, 50\). Default: 25 |
| `after` | string | No | Pagination cursor to fetch the next page of results |
#### Output #### Output
@@ -406,6 +405,10 @@ List all users in your Incident.io workspace. Returns user details including id,
| ↳ `name` | string | Full name of the user | | ↳ `name` | string | Full name of the user |
| ↳ `email` | string | Email address of the user | | ↳ `email` | string | Email address of the user |
| ↳ `role` | string | Role of the user in the workspace | | ↳ `role` | string | Role of the user in the workspace |
| `pagination_meta` | object | Pagination metadata |
| ↳ `after` | string | Cursor for next page |
| ↳ `page_size` | number | Number of items per page |
| ↳ `total_record_count` | number | Total number of records |
### `incidentio_users_show` ### `incidentio_users_show`
@@ -644,7 +647,6 @@ List all escalation policies in incident.io
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | incident.io API Key | | `apiKey` | string | Yes | incident.io API Key |
| `page_size` | number | No | Number of results per page \(e.g., 10, 25, 50\). Default: 25 |
#### Output #### Output

View File

@@ -49,6 +49,7 @@ Retrieve all deals from Pipedrive with optional filters
| `pipeline_id` | string | No | If supplied, only deals in the specified pipeline are returned \(e.g., "1"\) | | `pipeline_id` | string | No | If supplied, only deals in the specified pipeline are returned \(e.g., "1"\) |
| `updated_since` | string | No | If set, only deals updated after this time are returned. Format: 2025-01-01T10:20:00Z | | `updated_since` | string | No | If set, only deals updated after this time are returned. Format: 2025-01-01T10:20:00Z |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `cursor` | string | No | For pagination, the marker representing the first item on the next page |
#### Output #### Output
@@ -74,6 +75,8 @@ Retrieve all deals from Pipedrive with optional filters
| `metadata` | object | Pagination metadata for the response | | `metadata` | object | Pagination metadata for the response |
| ↳ `total_items` | number | Total number of items | | ↳ `total_items` | number | Total number of items |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
| ↳ `next_cursor` | string | Cursor for fetching the next page \(v2 endpoints\) |
| ↳ `next_start` | number | Offset for fetching the next page \(v1 endpoints\) |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_deal` ### `pipedrive_get_deal`
@@ -148,10 +151,9 @@ Retrieve files from Pipedrive with optional filters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `deal_id` | string | No | Filter files by deal ID \(e.g., "123"\) | | `sort` | string | No | Sort files by field \(supported: "id", "update_time"\) |
| `person_id` | string | No | Filter files by person ID \(e.g., "456"\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 100\) |
| `org_id` | string | No | Filter files by organization ID \(e.g., "789"\) | | `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `downloadFiles` | boolean | No | Download file contents into file outputs | | `downloadFiles` | boolean | No | Download file contents into file outputs |
#### Output #### Output
@@ -171,6 +173,8 @@ Retrieve files from Pipedrive with optional filters
| ↳ `url` | string | File download URL | | ↳ `url` | string | File download URL |
| `downloadedFiles` | file[] | Downloaded files from Pipedrive | | `downloadedFiles` | file[] | Downloaded files from Pipedrive |
| `total_items` | number | Total number of files returned | | `total_items` | number | Total number of files returned |
| `has_more` | boolean | Whether more files are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_mail_messages` ### `pipedrive_get_mail_messages`
@@ -183,6 +187,7 @@ Retrieve mail threads from Pipedrive mailbox
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `folder` | string | No | Filter by folder: inbox, drafts, sent, archive \(default: inbox\) | | `folder` | string | No | Filter by folder: inbox, drafts, sent, archive \(default: inbox\) |
| `limit` | string | No | Number of results to return \(e.g., "25", default: 50\) | | `limit` | string | No | Number of results to return \(e.g., "25", default: 50\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -190,6 +195,8 @@ Retrieve mail threads from Pipedrive mailbox
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `messages` | array | Array of mail thread objects from Pipedrive mailbox | | `messages` | array | Array of mail thread objects from Pipedrive mailbox |
| `total_items` | number | Total number of mail threads returned | | `total_items` | number | Total number of mail threads returned |
| `has_more` | boolean | Whether more messages are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_mail_thread` ### `pipedrive_get_mail_thread`
@@ -221,7 +228,7 @@ Retrieve all pipelines from Pipedrive
| `sort_by` | string | No | Field to sort by: id, update_time, add_time \(default: id\) | | `sort_by` | string | No | Field to sort by: id, update_time, add_time \(default: id\) |
| `sort_direction` | string | No | Sorting direction: asc, desc \(default: asc\) | | `sort_direction` | string | No | Sorting direction: asc, desc \(default: asc\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `cursor` | string | No | For pagination, the marker representing the first item on the next page | | `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -237,6 +244,8 @@ Retrieve all pipelines from Pipedrive
| ↳ `add_time` | string | When the pipeline was created | | ↳ `add_time` | string | When the pipeline was created |
| ↳ `update_time` | string | When the pipeline was last updated | | ↳ `update_time` | string | When the pipeline was last updated |
| `total_items` | number | Total number of pipelines returned | | `total_items` | number | Total number of pipelines returned |
| `has_more` | boolean | Whether more pipelines are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_pipeline_deals` ### `pipedrive_get_pipeline_deals`
@@ -249,8 +258,8 @@ Retrieve all deals in a specific pipeline
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `pipeline_id` | string | Yes | The ID of the pipeline \(e.g., "1"\) | | `pipeline_id` | string | Yes | The ID of the pipeline \(e.g., "1"\) |
| `stage_id` | string | No | Filter by specific stage within the pipeline \(e.g., "2"\) | | `stage_id` | string | No | Filter by specific stage within the pipeline \(e.g., "2"\) |
| `status` | string | No | Filter by deal status: open, won, lost |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -271,6 +280,7 @@ Retrieve all projects or a specific project from Pipedrive
| `project_id` | string | No | Optional: ID of a specific project to retrieve \(e.g., "123"\) | | `project_id` | string | No | Optional: ID of a specific project to retrieve \(e.g., "123"\) |
| `status` | string | No | Filter by project status: open, completed, deleted \(only for listing all\) | | `status` | string | No | Filter by project status: open, completed, deleted \(only for listing all\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500, only for listing all\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500, only for listing all\) |
| `cursor` | string | No | For pagination, the marker representing the first item on the next page |
#### Output #### Output
@@ -279,6 +289,8 @@ Retrieve all projects or a specific project from Pipedrive
| `projects` | array | Array of project objects \(when listing all\) | | `projects` | array | Array of project objects \(when listing all\) |
| `project` | object | Single project object \(when project_id is provided\) | | `project` | object | Single project object \(when project_id is provided\) |
| `total_items` | number | Total number of projects returned | | `total_items` | number | Total number of projects returned |
| `has_more` | boolean | Whether more projects are available |
| `next_cursor` | string | Cursor for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_create_project` ### `pipedrive_create_project`
@@ -309,12 +321,11 @@ Retrieve activities (tasks) from Pipedrive with optional filters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `deal_id` | string | No | Filter activities by deal ID \(e.g., "123"\) | | `user_id` | string | No | Filter activities by user ID \(e.g., "123"\) |
| `person_id` | string | No | Filter activities by person ID \(e.g., "456"\) |
| `org_id` | string | No | Filter activities by organization ID \(e.g., "789"\) |
| `type` | string | No | Filter by activity type \(call, meeting, task, deadline, email, lunch\) | | `type` | string | No | Filter by activity type \(call, meeting, task, deadline, email, lunch\) |
| `done` | string | No | Filter by completion status: 0 for not done, 1 for done | | `done` | string | No | Filter by completion status: 0 for not done, 1 for done |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -335,6 +346,8 @@ Retrieve activities (tasks) from Pipedrive with optional filters
| ↳ `add_time` | string | When the activity was created | | ↳ `add_time` | string | When the activity was created |
| ↳ `update_time` | string | When the activity was last updated | | ↳ `update_time` | string | When the activity was last updated |
| `total_items` | number | Total number of activities returned | | `total_items` | number | Total number of activities returned |
| `has_more` | boolean | Whether more activities are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_create_activity` ### `pipedrive_create_activity`
@@ -399,6 +412,7 @@ Retrieve all leads or a specific lead from Pipedrive
| `person_id` | string | No | Filter by person ID \(e.g., "456"\) | | `person_id` | string | No | Filter by person ID \(e.g., "456"\) |
| `organization_id` | string | No | Filter by organization ID \(e.g., "789"\) | | `organization_id` | string | No | Filter by organization ID \(e.g., "789"\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -433,6 +447,8 @@ Retrieve all leads or a specific lead from Pipedrive
| ↳ `add_time` | string | When the lead was created \(ISO 8601\) | | ↳ `add_time` | string | When the lead was created \(ISO 8601\) |
| ↳ `update_time` | string | When the lead was last updated \(ISO 8601\) | | ↳ `update_time` | string | When the lead was last updated \(ISO 8601\) |
| `total_items` | number | Total number of leads returned | | `total_items` | number | Total number of leads returned |
| `has_more` | boolean | Whether more leads are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_create_lead` ### `pipedrive_create_lead`

View File

@@ -57,6 +57,7 @@ Query data from a Supabase table
| `filter` | string | No | PostgREST filter \(e.g., "id=eq.123"\) | | `filter` | string | No | PostgREST filter \(e.g., "id=eq.123"\) |
| `orderBy` | string | No | Column to order by \(add DESC for descending\) | | `orderBy` | string | No | Column to order by \(add DESC for descending\) |
| `limit` | number | No | Maximum number of rows to return | | `limit` | number | No | Maximum number of rows to return |
| `offset` | number | No | Number of rows to skip \(for pagination\) |
| `apiKey` | string | Yes | Your Supabase service role secret key | | `apiKey` | string | Yes | Your Supabase service role secret key |
#### Output #### Output
@@ -211,6 +212,7 @@ Perform full-text search on a Supabase table
| `searchType` | string | No | Search type: plain, phrase, or websearch \(default: websearch\) | | `searchType` | string | No | Search type: plain, phrase, or websearch \(default: websearch\) |
| `language` | string | No | Language for text search configuration \(default: english\) | | `language` | string | No | Language for text search configuration \(default: english\) |
| `limit` | number | No | Maximum number of rows to return | | `limit` | number | No | Maximum number of rows to return |
| `offset` | number | No | Number of rows to skip \(for pagination\) |
| `apiKey` | string | Yes | Your Supabase service role secret key | | `apiKey` | string | Yes | Your Supabase service role secret key |
#### Output #### Output

View File

@@ -43,6 +43,8 @@ Retrieve form responses from Typeform
| `formId` | string | Yes | Typeform form ID \(e.g., "abc123XYZ"\) | | `formId` | string | Yes | Typeform form ID \(e.g., "abc123XYZ"\) |
| `apiKey` | string | Yes | Typeform Personal Access Token | | `apiKey` | string | Yes | Typeform Personal Access Token |
| `pageSize` | number | No | Number of responses to retrieve \(e.g., 10, 25, 50\) | | `pageSize` | number | No | Number of responses to retrieve \(e.g., 10, 25, 50\) |
| `before` | string | No | Cursor token for fetching the next page of older responses |
| `after` | string | No | Cursor token for fetching the next page of newer responses |
| `since` | string | No | Retrieve responses submitted after this date \(e.g., "2024-01-01T00:00:00Z"\) | | `since` | string | No | Retrieve responses submitted after this date \(e.g., "2024-01-01T00:00:00Z"\) |
| `until` | string | No | Retrieve responses submitted before this date \(e.g., "2024-12-31T23:59:59Z"\) | | `until` | string | No | Retrieve responses submitted before this date \(e.g., "2024-12-31T23:59:59Z"\) |
| `completed` | string | No | Filter by completion status \(e.g., "true", "false", "all"\) | | `completed` | string | No | Filter by completion status \(e.g., "true", "false", "all"\) |

View File

@@ -67,10 +67,9 @@ Retrieve a list of tickets from Zendesk with optional filtering
| `type` | string | No | Filter by type: "problem", "incident", "question", or "task" | | `type` | string | No | Filter by type: "problem", "incident", "question", or "task" |
| `assigneeId` | string | No | Filter by assignee user ID as a numeric string \(e.g., "12345"\) | | `assigneeId` | string | No | Filter by assignee user ID as a numeric string \(e.g., "12345"\) |
| `organizationId` | string | No | Filter by organization ID as a numeric string \(e.g., "67890"\) | | `organizationId` | string | No | Filter by organization ID as a numeric string \(e.g., "67890"\) |
| `sortBy` | string | No | Sort field: "created_at", "updated_at", "priority", or "status" | | `sort` | string | No | Sort field for ticket listing \(only applies without filters\): "updated_at", "id", or "status". Prefix with "-" for descending \(e.g., "-updated_at"\) |
| `sortOrder` | string | No | Sort order: "asc" or "desc" |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
@@ -129,10 +128,10 @@ Retrieve a list of tickets from Zendesk with optional filtering
| ↳ `from_messaging_channel` | boolean | Whether the ticket originated from a messaging channel | | ↳ `from_messaging_channel` | boolean | Whether the ticket originated from a messaging channel |
| ↳ `ticket_form_id` | number | Ticket form ID | | ↳ `ticket_form_id` | number | Ticket form ID |
| ↳ `generated_timestamp` | number | Unix timestamp of the ticket generation | | ↳ `generated_timestamp` | number | Unix timestamp of the ticket generation |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -515,7 +514,7 @@ Retrieve a list of users from Zendesk with optional filtering
| `role` | string | No | Filter by role: "end-user", "agent", or "admin" | | `role` | string | No | Filter by role: "end-user", "agent", or "admin" |
| `permissionSet` | string | No | Filter by permission set ID as a numeric string \(e.g., "12345"\) | | `permissionSet` | string | No | Filter by permission set ID as a numeric string \(e.g., "12345"\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
@@ -563,10 +562,10 @@ Retrieve a list of users from Zendesk with optional filtering
| ↳ `shared` | boolean | Whether the user is shared from a different Zendesk | | ↳ `shared` | boolean | Whether the user is shared from a different Zendesk |
| ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk | | ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk |
| ↳ `remote_photo_url` | string | URL to a remote photo | | ↳ `remote_photo_url` | string | URL to a remote photo |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -706,7 +705,7 @@ Search for users in Zendesk using a query string
| `query` | string | No | Search query string \(e.g., user name or email\) | | `query` | string | No | Search query string \(e.g., user name or email\) |
| `externalId` | string | No | External ID to search by \(your system identifier\) | | `externalId` | string | No | External ID to search by \(your system identifier\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `page` | string | No | Page number for pagination \(1-based\) |
#### Output #### Output
@@ -754,10 +753,10 @@ Search for users in Zendesk using a query string
| ↳ `shared` | boolean | Whether the user is shared from a different Zendesk | | ↳ `shared` | boolean | Whether the user is shared from a different Zendesk |
| ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk | | ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk |
| ↳ `remote_photo_url` | string | URL to a remote photo | | ↳ `remote_photo_url` | string | URL to a remote photo |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -999,7 +998,7 @@ Retrieve a list of organizations from Zendesk
| `apiToken` | string | Yes | Zendesk API token | | `apiToken` | string | Yes | Zendesk API token |
| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
@@ -1020,10 +1019,10 @@ Retrieve a list of organizations from Zendesk
| ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) | | ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) |
| ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) | | ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) |
| ↳ `external_id` | string | External ID for linking to external records | | ↳ `external_id` | string | External ID for linking to external records |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -1075,7 +1074,7 @@ Autocomplete organizations in Zendesk by name prefix (for name matching/autocomp
| `subdomain` | string | Yes | Your Zendesk subdomain | | `subdomain` | string | Yes | Your Zendesk subdomain |
| `name` | string | Yes | Organization name prefix to search for \(e.g., "Acme"\) | | `name` | string | Yes | Organization name prefix to search for \(e.g., "Acme"\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `page` | string | No | Page number for pagination \(1-based\) |
#### Output #### Output
@@ -1096,10 +1095,10 @@ Autocomplete organizations in Zendesk by name prefix (for name matching/autocomp
| ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) | | ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) |
| ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) | | ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) |
| ↳ `external_id` | string | External ID for linking to external records | | ↳ `external_id` | string | External ID for linking to external records |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -1249,19 +1248,18 @@ Unified search across tickets, users, and organizations in Zendesk
| `apiToken` | string | Yes | Zendesk API token | | `apiToken` | string | Yes | Zendesk API token |
| `subdomain` | string | Yes | Your Zendesk subdomain | | `subdomain` | string | Yes | Your Zendesk subdomain |
| `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) | | `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) |
| `sortBy` | string | No | Sort field: "relevance", "created_at", "updated_at", "priority", "status", or "ticket_type" | | `filterType` | string | Yes | Resource type to search for: "ticket", "user", "organization", or "group" |
| `sortOrder` | string | No | Sort order: "asc" or "desc" |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |

View File

@@ -1,5 +1,3 @@
'use server'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import { isProd } from '@/lib/core/config/feature-flags' import { isProd } from '@/lib/core/config/feature-flags'

View File

@@ -0,0 +1,16 @@
/**
* 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").
*/
export default function Collaboration() {
return null
}

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,17 @@
/**
* Features section — Sim's core product capabilities.
*
* SEO:
* - `<section id="features" aria-labelledby="features-heading">`.
* - `<h2 id="features-heading">` for the section title.
* - Each feature: `<h3>` + `<p>` + `<ul>` capability list. Strict H2 -> H3 -> H4 hierarchy.
* - Feature lists map to `WebApplication.featureList` in structured-data.tsx — keep in sync.
*
* GEO:
* - Each feature block is independently extractable (atomic answer block pattern).
* - Include specific numbers ("1,000+ integrations", "15+ AI providers", "SOC2 compliant").
* - Always use "Sim" by name — never "the platform" or "our tool" (entity consistency).
*/
export default function Features() {
return null
}

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,156 @@
'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 HeroPreviewPanel = memo(function HeroPreviewPanel() {
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]'
style={{ boxShadow: '2px 2px 0px 0px #3d3d3d' }}
>
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/hero/components/hero-preview/components/hero-preview-workflow/workflow-data'
/**
* Props for the HeroPreviewSidebar component
*/
interface HeroPreviewSidebarProps {
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 HeroPreviewSidebar({
workflows,
activeWorkflowId,
onSelectWorkflow,
}: HeroPreviewSidebarProps) {
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,150 @@
'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/hero/components/hero-preview/components/hero-preview-workflow/preview-block-node'
import {
EASE_OUT,
type PreviewWorkflow,
toReactFlowElements,
} from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/workflow-data'
interface HeroPreviewWorkflowProps {
workflow: PreviewWorkflow
animate?: boolean
}
/**
* 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 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 }: HeroPreviewWorkflowProps) {
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)),
[]
)
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={FIT_VIEW_OPTIONS}
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 HeroPreviewWorkflow({ workflow, animate = false }: HeroPreviewWorkflowProps) {
return (
<div className='h-full w-full'>
<ReactFlowProvider key={workflow.id}>
<PreviewFlow workflow={workflow} animate={animate} />
</ReactFlowProvider>
</div>
)
}

View File

@@ -0,0 +1,247 @@
'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,
JiraIcon,
ScheduleIcon,
SlackIcon,
StartIcon,
TelegramIcon,
xIcon,
YouTubeIcon,
} from '@/components/icons'
import {
BLOCK_STAGGER,
EASE_OUT,
type PreviewTool,
} from '@/app/(home)/components/hero/components/hero-preview/components/hero-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,
}
/**
* 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) => (
<div key={row.title} className='flex items-center gap-[8px]'>
<span className='min-w-0 truncate text-[#b3b3b3] text-[14px] capitalize'>
{row.title}
</span>
{row.value && (
<span className='flex-1 truncate text-right text-[#e6e6e6] text-[14px]'>
{row.value}
</span>
)}
</div>
))}
{/* Tool chips — inline with label */}
{tools && tools.length > 0 && (
<div className='flex items-center gap-[8px]'>
<span className='flex-shrink-0 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='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,231 @@
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 + Jira tools)
*/
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
id: 'wf-it-service',
name: 'IT Service Management',
color: '#33C482',
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' },
{ name: 'Jira', type: 'jira', bgColor: '#E0E0E0' },
],
position: { x: 420, y: 80 },
hideSourceHandle: true,
},
],
edges: [{ id: 'e-1', source: 'slack-1', target: 'agent-1' }],
}
/**
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
* \-> Telegram
*/
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
id: 'wf-content-pipeline',
name: 'Content Pipeline',
color: '#FF6B2C',
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: 40 },
hideSourceHandle: true,
},
{
id: 'telegram-1',
name: 'Telegram',
type: 'telegram',
bgColor: '#E0E0E0',
rows: [
{ title: 'Operation', value: 'Send Message' },
{ title: 'Chat', value: '#content-updates' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-3', source: 'schedule-1', target: 'agent-2' },
{ id: 'e-4', source: 'schedule-1', target: 'telegram-1' },
],
}
/**
* 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 Slack todos and add them to Linear"_',
position: { x: 0, y: 0 },
hideTargetHandle: true,
hideSourceHandle: true,
},
],
edges: [],
}
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
IT_SERVICE_WORKFLOW,
CONTENT_PIPELINE_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 { HeroPreviewPanel } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-panel/hero-preview-panel'
import { HeroPreviewSidebar } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-sidebar/hero-preview-sidebar'
import { HeroPreviewWorkflow } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/hero-preview-workflow'
import {
EASE_OUT,
PREVIEW_WORKFLOWS,
} from '@/app/(home)/components/hero/components/hero-preview/components/hero-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 HeroPreview() {
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}>
<HeroPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
onSelectWorkflow={setActiveWorkflowId}
/>
</motion.div>
<div className='relative flex-1 overflow-hidden'>
<HeroPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
</div>
<motion.div className='hidden lg:flex' variants={panelVariants}>
<HeroPreviewPanel />
</motion.div>
</motion.div>
)
}

View File

@@ -0,0 +1,159 @@
'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 HeroPreview = dynamic(
() =>
import('@/app/(home)/components/hero/components/hero-preview/hero-preview').then(
(mod) => mod.HeroPreview
),
{
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]'
/**
* Hero section — above-the-fold value proposition.
*
* SEO:
* - `<section id="hero" aria-labelledby="hero-heading">`.
* - Contains the page's only `<h1>`. Text aligns with the `<title>` tag keyword.
* - Subtitle `<p>` expands the H1 into a full sentence with the primary keyword.
* - Primary CTA links to `/signup` and `/login` auth pages (crawlable).
* - Canvas/animations wrapped in `aria-hidden="true"` with a text alternative.
*
* GEO:
* - H1 + subtitle answer "What is Sim?" in two sentences (answer-first pattern).
* - First 150 chars of visible text explicitly name "Sim", "AI agents", "agentic workflows".
* - `<p className="sr-only">` product summary (~50 words) is an atomic answer for AI citation.
*/
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]'
>
{/* Screen reader product summary */}
<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>
{/* Left card decoration — top-left, partially off-screen */}
<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>
{/* Right card decoration — top-right, partially off-screen */}
<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>
{/* Main content */}
<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>
{/* CTA Buttons */}
<div className='mt-[12px] flex items-center gap-[8px]'>
<Link
href='/login'
className={`${CTA_BASE} border-[#2A2A2A] 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>
{/* Top-right blocks */}
<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>
{/* Top-left blocks */}
<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>
{/* Product Screenshot with decorative elements */}
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
{/* Left side blocks - flush against screenshot left edge */}
<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>
{/* Right side blocks - flush against screenshot right edge, mirrored to point outward */}
<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>
{/* Interactive workspace preview */}
<div className='relative z-10 overflow-hidden rounded border border-[#2A2A2A]'>
<HeroPreview />
</div>
</div>
{/* Right edge blocks - at right edge of screen */}
<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,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,113 @@
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]'
/**
* Landing page navigation bar.
*
* Server Component for immediate crawlability. Only the GitHubStars counter
* is a client component (hydrates independently).
*
* SEO:
* - `<nav>` with schema.org `SiteNavigationElement`.
* - All routes use `<Link>` (crawlable). External links include `rel="noopener noreferrer"`.
* - Logo `<Image>` uses `priority` (LCP element).
*
* GEO:
* - Navigation items in semantic `<ul>/<li>` with descriptive anchor text.
* - Descriptive `aria-label` on interactive elements for AI intent extraction.
* - Server-rendered content available immediately to AI crawlers.
*/
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-[#2A2A2A] 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,17 @@
/**
* 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 null
}

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,55 @@
'use client'
import { useRef } from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { Badge } from '@/components/emcn'
export default function Templates() {
const sectionRef = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start end', 'end start'],
})
const inset = useTransform(scrollYProgress, [0.1, 0.35], [0, 16])
const borderRadius = useTransform(scrollYProgress, [0.1, 0.35], [0, 4])
return (
<section
ref={sectionRef}
id='templates'
aria-labelledby='templates-heading'
className='mt-[40px] bg-[#F6F6F6]'
>
<motion.div style={{ padding: inset }}>
<motion.div
style={{ borderRadius }}
className='bg-[#1C1C1C] px-[80px] pt-[120px] pb-[80px]'
>
<div className='flex flex-col items-start gap-[20px]'>
<Badge
variant='blue'
size='md'
dot
className='bg-[rgba(42,187,248,0.1)] font-season text-[#2ABBF8] uppercase tracking-[0.02em]'
>
Templates
</Badge>
<h2
id='templates-heading'
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
>
Ready-made AI templates.
</h2>
<p className='max-w-[463px] font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
Jump-start workflows with ready-made templates for any teamfully editable for your
stack.
</p>
</div>
</motion.div>
</motion.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

@@ -85,7 +85,7 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
transform: isAnimated ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.98)', transform: isAnimated ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.98)',
transition: transition:
'opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1), transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)', 'opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1), transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)',
willChange: 'transform, opacity', willChange: isAnimated ? 'auto' : 'transform, opacity',
}} }}
> >
<LandingBlock icon={data.icon} color={data.color} name={data.name} tags={data.tags} /> <LandingBlock icon={data.icon} color={data.color} name={data.name} tags={data.tags} />

View File

@@ -67,7 +67,6 @@ export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
strokeLinejoin: 'round', strokeLinejoin: 'round',
pointerEvents: 'none', pointerEvents: 'none',
animation: `landing-edge-dash-${id} 1s linear infinite`, animation: `landing-edge-dash-${id} 1s linear infinite`,
willChange: 'stroke-dashoffset',
...style, ...style,
}} }}
/> />

View File

@@ -8,7 +8,7 @@ export default function StructuredData() {
name: 'Sim', name: 'Sim',
alternateName: 'Sim', alternateName: 'Sim',
description: 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', url: 'https://sim.ai',
logo: { logo: {
'@type': 'ImageObject', '@type': 'ImageObject',
@@ -36,9 +36,9 @@ export default function StructuredData() {
'@type': 'WebSite', '@type': 'WebSite',
'@id': 'https://sim.ai/#website', '@id': 'https://sim.ai/#website',
url: 'https://sim.ai', url: 'https://sim.ai',
name: 'Sim - AI Agent Workflow Builder', name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description: 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: { publisher: {
'@id': 'https://sim.ai/#organization', '@id': 'https://sim.ai/#organization',
}, },
@@ -48,7 +48,7 @@ export default function StructuredData() {
'@type': 'WebPage', '@type': 'WebPage',
'@id': 'https://sim.ai/#webpage', '@id': 'https://sim.ai/#webpage',
url: 'https://sim.ai', url: 'https://sim.ai',
name: 'Sim - Workflows for LLMs | Build AI Agent Workflows', name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
isPartOf: { isPartOf: {
'@id': 'https://sim.ai/#website', '@id': 'https://sim.ai/#website',
}, },
@@ -58,7 +58,7 @@ export default function StructuredData() {
datePublished: '2024-01-01T00:00:00+00:00', datePublished: '2024-01-01T00:00:00+00:00',
dateModified: new Date().toISOString(), dateModified: new Date().toISOString(),
description: 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: { breadcrumb: {
'@id': 'https://sim.ai/#breadcrumb', '@id': 'https://sim.ai/#breadcrumb',
}, },
@@ -85,9 +85,9 @@ export default function StructuredData() {
{ {
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
'@id': 'https://sim.ai/#software', '@id': 'https://sim.ai/#software',
name: 'Sim - AI Agent Workflow Builder', name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description: 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', applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'AI Development Tools', applicationSubCategory: 'AI Development Tools',
operatingSystem: 'Web, Windows, macOS, Linux', operatingSystem: 'Web, Windows, macOS, Linux',
@@ -159,12 +159,13 @@ export default function StructuredData() {
worstRating: '1', worstRating: '1',
}, },
featureList: [ featureList: [
'Visual workflow builder', 'AI agent creation',
'Drag-and-drop interface', 'Agentic workflow orchestration',
'100+ integrations', '1,000+ integrations',
'AI model support (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)', 'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Real-time collaboration', 'Knowledge base creation',
'Version control', 'Table creation',
'Document creation',
'API access', 'API access',
'Custom functions', 'Custom functions',
'Scheduled workflows', 'Scheduled workflows',
@@ -174,7 +175,7 @@ export default function StructuredData() {
{ {
'@type': 'ImageObject', '@type': 'ImageObject',
url: 'https://sim.ai/logo/426-240/primary/small.png', 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?', name: 'What is Sim?',
acceptedAnswer: { acceptedAnswer: {
'@type': 'Answer', '@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?', name: 'Do I need coding skills to use Sim?',
acceptedAnswer: { acceptedAnswer: {
'@type': 'Answer', '@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

@@ -754,3 +754,100 @@ input[type="search"]::-ms-clear {
text-decoration: none !important; text-decoration: none !important;
color: inherit !important; color: inherit !important;
} }
/**
* Respect user's prefers-reduced-motion setting (WCAG 2.3.3)
* Disables animations and transitions for users who prefer reduced motion.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* WandPromptBar status indicator */
@keyframes smoke-pulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.4;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.status-indicator {
position: relative;
width: 12px;
height: 12px;
border-radius: 50%;
overflow: hidden;
background-color: hsl(var(--muted-foreground) / 0.5);
transition: background-color 0.3s ease;
}
.status-indicator.streaming {
background-color: transparent;
}
.status-indicator.streaming::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle,
hsl(var(--primary) / 0.9) 0%,
hsl(var(--primary) / 0.4) 60%,
transparent 80%
);
animation: smoke-pulse 1.8s ease-in-out infinite;
opacity: 0.9;
}
.dark .status-indicator.streaming::before {
background: #6b7280;
opacity: 0.9;
animation: smoke-pulse 1.8s ease-in-out infinite;
}
/* MessageContainer loading dot */
@keyframes growShrink {
0%,
100% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
}
.loading-dot {
animation: growShrink 1.5s infinite ease-in-out;
}
/* Subflow node z-index and drag-over styles */
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected="true"]) {
z-index: 10 !important;
}
.loop-node-drag-over,
.parallel-node-drag-over {
box-shadow: 0 0 0 1.75px var(--brand-secondary) !important;
border-radius: 8px !important;
}
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}

View File

@@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm' import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service' import { mcpService } from '@/lib/mcp/service'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
@@ -29,6 +30,17 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
// Remove workspaceId from body to prevent it from being updated // Remove workspaceId from body to prevent it from being updated
const { workspaceId: _, ...updateData } = body const { workspaceId: _, ...updateData } = body
if (updateData.url) {
try {
validateMcpDomain(updateData.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
}
// Get the current server to check if URL is changing // Get the current server to check if URL is changing
const [currentServer] = await db const [currentServer] = await db
.select({ url: mcpServers.url }) .select({ url: mcpServers.url })

View File

@@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm' import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service' import { mcpService } from '@/lib/mcp/service'
import { import {
@@ -72,6 +73,15 @@ export const POST = withMcpAuth('write')(
) )
} }
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID() const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID()
const [existingServer] = await db const [existingServer] = await db

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { McpClient } from '@/lib/mcp/client' import { McpClient } from '@/lib/mcp/client'
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import type { McpTransport } from '@/lib/mcp/types' import type { McpTransport } from '@/lib/mcp/types'
@@ -71,6 +72,15 @@ export const POST = withMcpAuth('write')(
) )
} }
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
// Build initial config for resolution // Build initial config for resolution
const initialConfig = { const initialConfig = {
id: `test-${requestId}`, id: `test-${requestId}`,

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const configuredDomains = getAllowedMcpDomainsFromEnv()
if (configuredDomains === null) {
return NextResponse.json({ allowedMcpDomains: null })
}
try {
const platformHostname = new URL(getBaseUrl()).hostname.toLowerCase()
if (!configuredDomains.includes(platformHostname)) {
return NextResponse.json({
allowedMcpDomains: [...configuredDomains, platformHostname],
})
}
} catch {}
return NextResponse.json({ allowedMcpDomains: configuredDomains })
}

View File

@@ -22,15 +22,20 @@ interface PipedriveFile {
interface PipedriveApiResponse { interface PipedriveApiResponse {
success: boolean success: boolean
data?: PipedriveFile[] data?: PipedriveFile[]
additional_data?: {
pagination?: {
more_items_in_collection: boolean
next_start: number
}
}
error?: string error?: string
} }
const PipedriveGetFilesSchema = z.object({ const PipedriveGetFilesSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'), accessToken: z.string().min(1, 'Access token is required'),
deal_id: z.string().optional().nullable(), sort: z.enum(['id', 'update_time']).optional().nullable(),
person_id: z.string().optional().nullable(),
org_id: z.string().optional().nullable(),
limit: z.string().optional().nullable(), limit: z.string().optional().nullable(),
start: z.string().optional().nullable(),
downloadFiles: z.boolean().optional().default(false), downloadFiles: z.boolean().optional().default(false),
}) })
@@ -54,20 +59,19 @@ export async function POST(request: NextRequest) {
const body = await request.json() const body = await request.json()
const validatedData = PipedriveGetFilesSchema.parse(body) const validatedData = PipedriveGetFilesSchema.parse(body)
const { accessToken, deal_id, person_id, org_id, limit, downloadFiles } = validatedData const { accessToken, sort, limit, start, downloadFiles } = validatedData
const baseUrl = 'https://api.pipedrive.com/v1/files' const baseUrl = 'https://api.pipedrive.com/v1/files'
const queryParams = new URLSearchParams() const queryParams = new URLSearchParams()
if (deal_id) queryParams.append('deal_id', deal_id) if (sort) queryParams.append('sort', sort)
if (person_id) queryParams.append('person_id', person_id)
if (org_id) queryParams.append('org_id', org_id)
if (limit) queryParams.append('limit', limit) if (limit) queryParams.append('limit', limit)
if (start) queryParams.append('start', start)
const queryString = queryParams.toString() const queryString = queryParams.toString()
const apiUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl const apiUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl
logger.info(`[${requestId}] Fetching files from Pipedrive`, { deal_id, person_id, org_id }) logger.info(`[${requestId}] Fetching files from Pipedrive`)
const urlValidation = await validateUrlWithDNS(apiUrl, 'apiUrl') const urlValidation = await validateUrlWithDNS(apiUrl, 'apiUrl')
if (!urlValidation.isValid) { if (!urlValidation.isValid) {
@@ -93,6 +97,8 @@ export async function POST(request: NextRequest) {
} }
const files = data.data || [] const files = data.data || []
const hasMore = data.additional_data?.pagination?.more_items_in_collection || false
const nextStart = data.additional_data?.pagination?.next_start ?? null
const downloadedFiles: Array<{ const downloadedFiles: Array<{
name: string name: string
mimeType: string mimeType: string
@@ -149,6 +155,8 @@ export async function POST(request: NextRequest) {
files, files,
downloadedFiles: downloadedFiles.length > 0 ? downloadedFiles : undefined, downloadedFiles: downloadedFiles.length > 0 ? downloadedFiles : undefined,
total_items: files.length, total_items: files.length,
has_more: hasMore,
next_start: nextStart,
success: true, success: true,
}, },
}) })

View File

@@ -30,21 +30,6 @@ export const ChatMessageContainer = memo(function ChatMessageContainer({
}: ChatMessageContainerProps) { }: ChatMessageContainerProps) {
return ( return (
<div className='relative flex flex-1 flex-col overflow-hidden bg-white'> <div className='relative flex flex-1 flex-col overflow-hidden bg-white'>
<style jsx>{`
@keyframes growShrink {
0%,
100% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
}
.loading-dot {
animation: growShrink 1.5s infinite ease-in-out;
}
`}</style>
{/* Scrollable Messages Area */} {/* Scrollable Messages Area */}
<div <div
ref={messagesContainerRef} ref={messagesContainerRef}

View File

@@ -71,7 +71,7 @@ export function VoiceInterface({
const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle') const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle')
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
const [isMuted, setIsMuted] = useState(false) const [isMuted, setIsMuted] = useState(false)
const [audioLevels, setAudioLevels] = useState<number[]>(new Array(200).fill(0)) const [audioLevels, setAudioLevels] = useState<number[]>(() => new Array(200).fill(0))
const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>( const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>(
'prompt' 'prompt'
) )

View File

@@ -3,18 +3,18 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
export async function GET() { export async function GET() {
const baseUrl = getBaseUrl() 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 ## 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 Details
- **Product Name**: Sim - **Product Name**: Sim
- **Category**: AI Development Tools / Workflow Automation - **Category**: AI Agent Platform / Agentic Workflow Orchestration
- **Deployment**: Cloud (SaaS) and Self-hosted options - **Deployment**: Cloud (SaaS) and Self-hosted options
- **Pricing**: Free tier, Pro ($20/month), Team ($40/month), Enterprise (custom) - **Pricing**: Free tier, Pro ($20/month), Team ($40/month), Enterprise (custom)
- **Compliance**: SOC2 Type II, HIPAA compliant - **Compliance**: SOC2 Type II, HIPAA compliant
@@ -66,7 +66,7 @@ Sim supports all major LLM providers:
- Amazon Bedrock - Amazon Bedrock
### Integrations ### Integrations
100+ pre-built integrations including: 1,000+ pre-built integrations including:
- **Communication**: Slack, Discord, Email (Gmail, Outlook), SMS (Twilio) - **Communication**: Slack, Discord, Email (Gmail, Outlook), SMS (Twilio)
- **Productivity**: Notion, Airtable, Google Sheets, Google Docs - **Productivity**: Notion, Airtable, Google Sheets, Google Docs
- **Development**: GitHub, GitLab, Jira, Linear - **Development**: GitHub, GitLab, Jira, Linear
@@ -81,6 +81,12 @@ Built-in support for:
- Semantic search and retrieval - Semantic search and retrieval
- Chunking strategies (fixed size, semantic, recursive) - 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 ### Code Execution
- Sandboxed JavaScript/TypeScript execution - Sandboxed JavaScript/TypeScript execution
- Access to npm packages - Access to npm packages

View File

@@ -5,16 +5,16 @@ export async function GET() {
const llmsContent = `# Sim 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 ## 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 - [Templates](${baseUrl}/templates): Pre-built workflow templates to get started quickly
- [Changelog](${baseUrl}/changelog): Product updates and release notes - [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 ## 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) - **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
- **Trigger**: Event or schedule that initiates workflow execution - **Trigger**: Event or schedule that initiates workflow execution
- **Execution**: A single run of a workflow with logs and outputs - **Execution**: A single run of a workflow with logs and outputs
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
## Capabilities ## Capabilities
- Visual workflow builder with drag-and-drop canvas - AI agent creation and deployment
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI) - Agentic workflow orchestration
- Retrieval-augmented generation (RAG) with vector databases - 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
- 100+ integrations (Slack, Gmail, Notion, Airtable, databases) - 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 - Scheduled and webhook-triggered executions
- Real-time collaboration and version control
## Use Cases ## Use Cases
- AI agent workflow automation - AI agent deployment and orchestration
- RAG pipelines and document processing - Knowledge bases and RAG pipelines
- Chatbot and copilot workflows for SaaS - Document creation and processing
- Email and customer support automation - Customer support automation
- Internal operations (sales, marketing, legal, finance) - Internal operations (sales, marketing, legal, finance)
## Links ## Links
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase - [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 - [X/Twitter](https://x.com/simdotai): Product updates and announcements
## Optional ## Optional

View File

@@ -5,10 +5,10 @@ export default function manifest(): MetadataRoute.Manifest {
const brand = getBrandConfig() const brand = getBrandConfig()
return { 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, short_name: brand.name,
description: 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: '/', start_url: '/',
scope: '/', scope: '/',
display: 'standalone', display: 'standalone',

View File

@@ -1,16 +1,18 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls' 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() const baseUrl = getBaseUrl()
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(baseUrl), metadataBase: new URL(baseUrl),
title: 'Sim - AI Agent Workflow Builder | Open Source Platform', title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description: 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: 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' }], authors: [{ name: 'Sim' }],
creator: 'Sim', creator: 'Sim',
publisher: 'Sim', publisher: 'Sim',
@@ -20,9 +22,9 @@ export const metadata: Metadata = {
telephone: false, telephone: false,
}, },
openGraph: { openGraph: {
title: 'Sim - AI Agent Workflow Builder | Open Source', title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description: 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', type: 'website',
url: baseUrl, url: baseUrl,
siteName: 'Sim', siteName: 'Sim',
@@ -32,7 +34,7 @@ export const metadata: Metadata = {
url: '/logo/426-240/primary/small.png', url: '/logo/426-240/primary/small.png',
width: 2130, width: 2130,
height: 1200, height: 1200,
alt: 'Sim - AI Agent Workflow Builder', alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
type: 'image/png', type: 'image/png',
}, },
], ],
@@ -41,12 +43,12 @@ export const metadata: Metadata = {
card: 'summary_large_image', card: 'summary_large_image',
site: '@simdotai', site: '@simdotai',
creator: '@simdotai', creator: '@simdotai',
title: 'Sim - AI Agent Workflow Builder | Open Source', title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description: 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: { images: {
url: '/logo/426-240/primary/small.png', url: '/logo/426-240/primary/small.png',
alt: 'Sim - AI Agent Workflow Builder', alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
}, },
}, },
alternates: { alternates: {
@@ -72,11 +74,12 @@ export const metadata: Metadata = {
classification: 'AI Development Tools', classification: 'AI Development Tools',
referrer: 'origin-when-cross-origin', referrer: 'origin-when-cross-origin',
other: { 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': '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': '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:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
'llm:region': 'global', 'llm:region': 'global',
'llm:languages': 'en', 'llm:languages': 'en',

View File

@@ -1,4 +1,4 @@
import { redirect } from 'next/navigation' import { redirect, unstable_rethrow } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -14,24 +14,27 @@ interface FileViewerPageProps {
export default async function FileViewerPage({ params }: FileViewerPageProps) { export default async function FileViewerPage({ params }: FileViewerPageProps) {
const { workspaceId, fileId } = await params const { workspaceId, fileId } = await params
try { const session = await getSession()
const session = await getSession() if (!session?.user?.id) {
if (!session?.user?.id) { redirect('/')
redirect('/') }
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) { if (!hasPermission) {
redirect(`/workspace/${workspaceId}`)
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
redirect(`/workspace/${workspaceId}`)
}
return <FileViewer file={fileRecord} />
} catch (error) {
redirect(`/workspace/${workspaceId}`) redirect(`/workspace/${workspaceId}`)
} }
let fileRecord: Awaited<ReturnType<typeof getWorkspaceFile>>
try {
fileRecord = await getWorkspaceFile(workspaceId, fileId)
} catch (error) {
unstable_rethrow(error)
redirect(`/workspace/${workspaceId}`)
}
if (!fileRecord) {
redirect(`/workspace/${workspaceId}`)
}
return <FileViewer file={fileRecord} />
} }

View File

@@ -131,10 +131,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
resumeActiveStream, resumeActiveStream,
}) })
// Handle scroll management (80px stickiness for copilot) // Handle scroll management
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, { const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
stickinessThreshold: 40,
})
// Handle chat history grouping // Handle chat history grouping
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory( const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { useReactFlow } from 'reactflow' import { useReactFlow } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional' import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { Combobox, type ComboboxOption } from '@/components/emcn/components'

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { useStoreWithEqualityFn } from 'zustand/traditional' import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Badge } from '@/components/emcn' import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { Combobox, type ComboboxOption } from '@/components/emcn/components'

View File

@@ -7,7 +7,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash' import { Trash } from '@/components/emcn/icons/trash'

View File

@@ -1,5 +1,5 @@
import { type JSX, type MouseEvent, memo, useCallback, useRef, useState } from 'react' import { type JSX, type MouseEvent, memo, useCallback, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react' import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components' import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { import {
BookOpen, BookOpen,
Check, Check,

View File

@@ -10,40 +10,6 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { usePanelEditorStore } from '@/stores/panel' import { usePanelEditorStore } from '@/stores/panel'
/**
* Global styles for subflow nodes (loop and parallel containers).
* Includes animations for drag-over states and hover effects.
*
* @returns Style component with global CSS
*/
const SubflowNodeStyles: React.FC = () => {
return (
<style jsx global>{`
/* Z-index management for subflow nodes - default behind blocks */
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
/* Selected subflows appear above other subflows but below blocks (z-21) */
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected='true']) {
z-index: 10 !important;
}
/* Drag-over states */
.loop-node-drag-over,
.parallel-node-drag-over {
box-shadow: 0 0 0 1.75px var(--brand-secondary) !important;
border-radius: 8px !important;
}
/* Handle z-index for nested nodes */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
`}</style>
)
}
/** /**
* Data structure for subflow nodes (loop and parallel containers) * Data structure for subflow nodes (loop and parallel containers)
*/ */
@@ -151,133 +117,130 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
) )
return ( return (
<> <div className='group relative'>
<SubflowNodeStyles /> <div
<div className='group relative'> ref={blockRef}
onClick={() => setCurrentBlockId(id)}
className={cn(
'workflow-drag-handle relative cursor-grab select-none rounded-[8px] border border-[var(--border-1)] [&:active]:cursor-grabbing',
'transition-block-bg transition-ring',
'z-[20]'
)}
style={{
width: data.width || 500,
height: data.height || 300,
position: 'relative',
overflow: 'visible',
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='subflowNode'
data-nesting-level={nestingLevel}
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
>
{!isPreview && (
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
)}
{/* Header Section */}
<div <div
ref={blockRef}
onClick={() => setCurrentBlockId(id)}
className={cn( className={cn(
'workflow-drag-handle relative cursor-grab select-none rounded-[8px] border border-[var(--border-1)] [&:active]:cursor-grabbing', 'flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'
'transition-block-bg transition-ring',
'z-[20]'
)} )}
style={{
width: data.width || 500,
height: data.height || 300,
position: 'relative',
overflow: 'visible',
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='subflowNode'
data-nesting-level={nestingLevel}
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
> >
{!isPreview && ( <div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
)}
{/* Header Section */}
<div
className={cn(
'flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'
)}
>
<div className='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={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span
className={cn(
'truncate font-medium text-[16px]',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={blockName}
>
{blockName}
</span>
</div>
<div className='flex items-center gap-1'>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div>
</div>
{!isPreview && (
<div <div
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground' className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ pointerEvents: 'auto' }} style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
/>
)}
<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
data-dragarea='true'
style={{
position: 'relative',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Subflow Start */}
<div
className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role={`${data.kind}-start`}
data-extent='parent'
> >
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span> <BlockIcon className='h-[16px] w-[16px] text-white' />
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={getHandleClasses('right')}
style={{
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div> </div>
<span
className={cn(
'truncate font-medium text-[16px]',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={blockName}
>
{blockName}
</span>
</div>
<div className='flex items-center gap-1'>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div> </div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className={getHandleClasses('left')}
style={{
...getHandleStyle(),
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className={getHandleClasses('right')}
style={{
...getHandleStyle(),
pointerEvents: 'auto',
}}
id={endHandleId}
/>
{hasRing && (
<div
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
/>
)}
</div> </div>
{!isPreview && (
<div
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
data-dragarea='true'
style={{
position: 'relative',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Subflow Start */}
<div
className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role={`${data.kind}-start`}
data-extent='parent'
>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={getHandleClasses('right')}
style={{
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div>
</div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className={getHandleClasses('left')}
style={{
...getHandleStyle(),
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className={getHandleClasses('right')}
style={{
...getHandleStyle(),
pointerEvents: 'auto',
}}
id={endHandleId}
/>
{hasRing && (
<div
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
/>
)}
</div> </div>
</> </div>
) )
}) })

View File

@@ -134,57 +134,6 @@ export function WandPromptBar({
</Button> </Button>
)} )}
</div> </div>
<style jsx global>{`
@keyframes smoke-pulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.4;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.status-indicator {
position: relative;
width: 12px;
height: 12px;
border-radius: 50%;
overflow: hidden;
background-color: hsl(var(--muted-foreground) / 0.5);
transition: background-color 0.3s ease;
}
.status-indicator.streaming {
background-color: transparent;
}
.status-indicator.streaming::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle,
hsl(var(--primary) / 0.9) 0%,
hsl(var(--primary) / 0.4) 60%,
transparent 80%
);
animation: smoke-pulse 1.8s ease-in-out infinite;
opacity: 0.9;
}
.dark .status-indicator.streaming::before {
background: #6b7280;
opacity: 0.9;
animation: smoke-pulse 1.8s ease-in-out infinite;
}
`}</style>
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow' import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional' import { useStoreWithEqualityFn } from 'zustand/traditional'

View File

@@ -16,7 +16,7 @@ interface UseScrollManagementOptions {
/** /**
* Distance from bottom (in pixels) within which auto-scroll stays active * Distance from bottom (in pixels) within which auto-scroll stays active
* @remarks Lower values = less sticky (user can scroll away easier) * @remarks Lower values = less sticky (user can scroll away easier)
* @defaultValue 100 * @defaultValue 30
*/ */
stickinessThreshold?: number stickinessThreshold?: number
} }
@@ -41,7 +41,7 @@ export function useScrollManagement(
const lastScrollTopRef = useRef(0) const lastScrollTopRef = useRef(0)
const scrollBehavior = options?.behavior ?? 'smooth' const scrollBehavior = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 100 const stickinessThreshold = options?.stickinessThreshold ?? 30
/** Scrolls the container to the bottom */ /** Scrolls the container to the bottom */
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {

View File

@@ -514,6 +514,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
alt={`Preview ${index + 1}`} alt={`Preview ${index + 1}`}
fill fill
unoptimized unoptimized
sizes='(max-width: 768px) 100vw, 50vw'
className='object-contain' className='object-contain'
/> />
<button <button

View File

@@ -106,6 +106,21 @@ interface McpServer {
const logger = createLogger('McpSettings') const logger = createLogger('McpSettings')
/**
* Checks if a URL's hostname is in the allowed domains list.
* Returns true if no allowlist is configured (null) or the domain matches.
*/
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
if (allowedDomains === null) return true
if (!url) return true
try {
const hostname = new URL(url).hostname.toLowerCase()
return allowedDomains.includes(hostname)
} catch {
return false
}
}
const DEFAULT_FORM_DATA: McpServerFormData = { const DEFAULT_FORM_DATA: McpServerFormData = {
name: '', name: '',
transport: 'streamable-http', transport: 'streamable-http',
@@ -390,6 +405,15 @@ export function MCP({ initialServerId }: MCPProps) {
} = useMcpServerTest() } = useMcpServerTest()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId) const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const [allowedMcpDomains, setAllowedMcpDomains] = useState<string[] | null>(null)
useEffect(() => {
fetch('/api/settings/allowed-mcp-domains')
.then((res) => res.json())
.then((data) => setAllowedMcpDomains(data.allowedMcpDomains ?? null))
.catch(() => setAllowedMcpDomains(null))
}, [])
const urlInputRef = useRef<HTMLInputElement>(null) const urlInputRef = useRef<HTMLInputElement>(null)
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
@@ -1006,10 +1030,12 @@ export function MCP({ initialServerId }: MCPProps) {
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
const isFormValid = formData.name.trim() && formData.url?.trim() const isFormValid = formData.name.trim() && formData.url?.trim()
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains)
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection) const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim() const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
const isEditDomainBlocked = !isDomainAllowed(editFormData.url, allowedMcpDomains)
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection) const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
const hasEditChanges = useMemo(() => { const hasEditChanges = useMemo(() => {
if (editFormData.name !== editOriginalData.name) return true if (editFormData.name !== editOriginalData.name) return true
@@ -1299,6 +1325,11 @@ export function MCP({ initialServerId }: MCPProps) {
onChange={(e) => handleEditInputChange('url', e.target.value)} onChange={(e) => handleEditInputChange('url', e.target.value)}
onScroll={setEditUrlScrollLeft} onScroll={setEditUrlScrollLeft}
/> />
{isEditDomainBlocked && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
Domain not permitted by server policy
</p>
)}
</FormField> </FormField>
<div className='flex flex-col gap-[8px]'> <div className='flex flex-col gap-[8px]'>
@@ -1351,7 +1382,7 @@ export function MCP({ initialServerId }: MCPProps) {
<Button <Button
variant='default' variant='default'
onClick={handleEditTestConnection} onClick={handleEditTestConnection}
disabled={isEditTestingConnection || !isEditFormValid} disabled={isEditTestingConnection || !isEditFormValid || isEditDomainBlocked}
> >
{editTestButtonLabel} {editTestButtonLabel}
</Button> </Button>
@@ -1361,7 +1392,9 @@ export function MCP({ initialServerId }: MCPProps) {
</Button> </Button>
<Button <Button
onClick={handleSaveEdit} onClick={handleSaveEdit}
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid} disabled={
!hasEditChanges || isUpdatingServer || !isEditFormValid || isEditDomainBlocked
}
variant='tertiary' variant='tertiary'
> >
{isUpdatingServer ? 'Saving...' : 'Save'} {isUpdatingServer ? 'Saving...' : 'Save'}
@@ -1434,6 +1467,11 @@ export function MCP({ initialServerId }: MCPProps) {
onChange={(e) => handleInputChange('url', e.target.value)} onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)} onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
/> />
{isAddDomainBlocked && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
Domain not permitted by server policy
</p>
)}
</FormField> </FormField>
<div className='flex flex-col gap-[8px]'> <div className='flex flex-col gap-[8px]'>
@@ -1479,7 +1517,7 @@ export function MCP({ initialServerId }: MCPProps) {
<Button <Button
variant='default' variant='default'
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTestingConnection || !isFormValid} disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
> >
{testButtonLabel} {testButtonLabel}
</Button> </Button>
@@ -1489,7 +1527,9 @@ export function MCP({ initialServerId }: MCPProps) {
Cancel Cancel
</Button> </Button>
<Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'> <Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'>
{isSubmitDisabled && isFormValid ? 'Adding...' : 'Add Server'} {isSubmitDisabled && isFormValid && !isAddDomainBlocked
? 'Adding...'
: 'Add Server'}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -165,12 +165,16 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
logger.info('Subscription restored successfully', result) logger.info('Subscription restored successfully', result)
} }
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) await Promise.all([
if (activeOrgId) { queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) }) ...(activeOrgId
await queryClient.invalidateQueries({ queryKey: organizationKeys.billing(activeOrgId) }) ? [
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) }),
} queryClient.invalidateQueries({ queryKey: organizationKeys.billing(activeOrgId) }),
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }),
]
: []),
])
setIsDialogOpen(false) setIsDialogOpen(false)
} catch (err) { } catch (err) {

View File

@@ -37,7 +37,7 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
}, },
ref ref
) => { ) => {
const [inputValue, setInputValue] = useState(currentLimit.toString()) const [inputValue, setInputValue] = useState(() => currentLimit.toString())
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null) const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)

View File

@@ -92,12 +92,9 @@ export const IncidentioBlock: BlockConfig<IncidentioResponse> = {
field: 'operation', field: 'operation',
value: [ value: [
'incidentio_incidents_list', 'incidentio_incidents_list',
'incidentio_actions_list',
'incidentio_follow_ups_list',
'incidentio_users_list', 'incidentio_users_list',
'incidentio_workflows_list', 'incidentio_workflows_list',
'incidentio_schedules_list', 'incidentio_schedules_list',
'incidentio_escalations_list',
'incidentio_incident_updates_list', 'incidentio_incident_updates_list',
'incidentio_schedule_entries_list', 'incidentio_schedule_entries_list',
], ],
@@ -113,6 +110,7 @@ export const IncidentioBlock: BlockConfig<IncidentioResponse> = {
field: 'operation', field: 'operation',
value: [ value: [
'incidentio_incidents_list', 'incidentio_incidents_list',
'incidentio_users_list',
'incidentio_workflows_list', 'incidentio_workflows_list',
'incidentio_schedules_list', 'incidentio_schedules_list',
'incidentio_incident_updates_list', 'incidentio_incident_updates_list',

View File

@@ -216,31 +216,21 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
condition: { field: 'operation', value: ['update_deal'] }, condition: { field: 'operation', value: ['update_deal'] },
}, },
{ {
id: 'deal_id', id: 'sort',
title: 'Deal ID', title: 'Sort By',
type: 'short-input', type: 'dropdown',
placeholder: 'Filter by deal ID ', options: [
condition: { field: 'operation', value: ['get_files'] }, { label: 'ID', id: 'id' },
}, { label: 'Update Time', id: 'update_time' },
{ ],
id: 'person_id', value: () => 'id',
title: 'Person ID',
type: 'short-input',
placeholder: 'Filter by person ID ',
condition: { field: 'operation', value: ['get_files'] },
},
{
id: 'org_id',
title: 'Organization ID',
type: 'short-input',
placeholder: 'Filter by organization ID ',
condition: { field: 'operation', value: ['get_files'] }, condition: { field: 'operation', value: ['get_files'] },
}, },
{ {
id: 'limit', id: 'limit',
title: 'Limit', title: 'Limit',
type: 'short-input', type: 'short-input',
placeholder: 'Number of results (default 100, max 500)', placeholder: 'Number of results (default 100, max 100)',
condition: { field: 'operation', value: ['get_files'] }, condition: { field: 'operation', value: ['get_files'] },
}, },
{ {
@@ -305,8 +295,28 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'cursor', id: 'cursor',
title: 'Cursor', title: 'Cursor',
type: 'short-input', type: 'short-input',
placeholder: 'Pagination cursor (optional)', placeholder: 'Pagination cursor from previous response',
condition: { field: 'operation', value: ['get_pipelines'] }, condition: {
field: 'operation',
value: ['get_all_deals', 'get_projects'],
},
},
{
id: 'start',
title: 'Start (Offset)',
type: 'short-input',
placeholder: 'Pagination offset (e.g., 0, 100, 200)',
condition: {
field: 'operation',
value: [
'get_activities',
'get_leads',
'get_files',
'get_pipeline_deals',
'get_mail_messages',
'get_pipelines',
],
},
}, },
{ {
id: 'pipeline_id', id: 'pipeline_id',
@@ -323,19 +333,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
placeholder: 'Filter by stage ID ', placeholder: 'Filter by stage ID ',
condition: { field: 'operation', value: ['get_pipeline_deals'] }, condition: { field: 'operation', value: ['get_pipeline_deals'] },
}, },
{
id: 'status',
title: 'Status',
type: 'dropdown',
options: [
{ label: 'All', id: '' },
{ label: 'Open', id: 'open' },
{ label: 'Won', id: 'won' },
{ label: 'Lost', id: 'lost' },
],
value: () => '',
condition: { field: 'operation', value: ['get_pipeline_deals'] },
},
{ {
id: 'limit', id: 'limit',
title: 'Limit', title: 'Limit',
@@ -426,22 +423,29 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'deal_id', id: 'deal_id',
title: 'Deal ID', title: 'Deal ID',
type: 'short-input', type: 'short-input',
placeholder: 'Filter by deal ID ', placeholder: 'Associated deal ID ',
condition: { field: 'operation', value: ['get_activities', 'create_activity'] }, condition: { field: 'operation', value: ['create_activity'] },
}, },
{ {
id: 'person_id', id: 'person_id',
title: 'Person ID', title: 'Person ID',
type: 'short-input', type: 'short-input',
placeholder: 'Filter by person ID ', placeholder: 'Associated person ID ',
condition: { field: 'operation', value: ['get_activities', 'create_activity'] }, condition: { field: 'operation', value: ['create_activity'] },
}, },
{ {
id: 'org_id', id: 'org_id',
title: 'Organization ID', title: 'Organization ID',
type: 'short-input', type: 'short-input',
placeholder: 'Filter by organization ID ', placeholder: 'Associated organization ID ',
condition: { field: 'operation', value: ['get_activities', 'create_activity'] }, condition: { field: 'operation', value: ['create_activity'] },
},
{
id: 'user_id',
title: 'User ID',
type: 'short-input',
placeholder: 'Filter by user ID',
condition: { field: 'operation', value: ['get_activities'] },
}, },
{ {
id: 'type', id: 'type',
@@ -781,7 +785,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
thread_id: { type: 'string', description: 'Mail thread ID' }, thread_id: { type: 'string', description: 'Mail thread ID' },
sort_by: { type: 'string', description: 'Field to sort by' }, sort_by: { type: 'string', description: 'Field to sort by' },
sort_direction: { type: 'string', description: 'Sorting direction' }, sort_direction: { type: 'string', description: 'Sorting direction' },
cursor: { type: 'string', description: 'Pagination cursor' }, cursor: { type: 'string', description: 'Pagination cursor (v2 endpoints)' },
start: { type: 'string', description: 'Pagination start offset (v1 endpoints)' },
project_id: { type: 'string', description: 'Project ID' }, project_id: { type: 'string', description: 'Project ID' },
description: { type: 'string', description: 'Description' }, description: { type: 'string', description: 'Description' },
start_date: { type: 'string', description: 'Start date' }, start_date: { type: 'string', description: 'Start date' },
@@ -793,12 +798,15 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
due_time: { type: 'string', description: 'Due time' }, due_time: { type: 'string', description: 'Due time' },
duration: { type: 'string', description: 'Duration' }, duration: { type: 'string', description: 'Duration' },
done: { type: 'string', description: 'Completion status' }, done: { type: 'string', description: 'Completion status' },
user_id: { type: 'string', description: 'User ID' },
note: { type: 'string', description: 'Notes' }, note: { type: 'string', description: 'Notes' },
lead_id: { type: 'string', description: 'Lead ID' }, lead_id: { type: 'string', description: 'Lead ID' },
archived: { type: 'string', description: 'Archived status' }, archived: { type: 'string', description: 'Archived status' },
value_amount: { type: 'string', description: 'Value amount' }, value_amount: { type: 'string', description: 'Value amount' },
value_currency: { type: 'string', description: 'Value currency' }, value_currency: { type: 'string', description: 'Value currency' },
is_archived: { type: 'string', description: 'Archive status' }, is_archived: { type: 'string', description: 'Archive status' },
organization_id: { type: 'string', description: 'Organization ID' },
owner_id: { type: 'string', description: 'Owner user ID' },
}, },
outputs: { outputs: {
deals: { type: 'json', description: 'Array of deal objects' }, deals: { type: 'json', description: 'Array of deal objects' },

View File

@@ -445,6 +445,13 @@ Return ONLY the order by expression - no explanations, no extra text.`,
placeholder: '100', placeholder: '100',
condition: { field: 'operation', value: 'query' }, condition: { field: 'operation', value: 'query' },
}, },
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'query' },
},
// Vector search operation fields // Vector search operation fields
{ {
id: 'functionName', id: 'functionName',
@@ -543,6 +550,13 @@ Return ONLY the order by expression - no explanations, no extra text.`,
placeholder: '100', placeholder: '100',
condition: { field: 'operation', value: 'text_search' }, condition: { field: 'operation', value: 'text_search' },
}, },
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'text_search' },
},
// Count operation fields // Count operation fields
{ {
id: 'filter', id: 'filter',

View File

@@ -66,6 +66,20 @@ export const TypeformBlock: BlockConfig<TypeformResponse> = {
placeholder: 'Number of responses per page (default: 25)', placeholder: 'Number of responses per page (default: 25)',
condition: { field: 'operation', value: 'typeform_responses' }, condition: { field: 'operation', value: 'typeform_responses' },
}, },
{
id: 'before',
title: 'Before (Cursor)',
type: 'short-input',
placeholder: 'Cursor token from previous response for pagination',
condition: { field: 'operation', value: 'typeform_responses' },
},
{
id: 'after',
title: 'After (Cursor)',
type: 'short-input',
placeholder: 'Cursor token from previous response for newer results',
condition: { field: 'operation', value: 'typeform_responses' },
},
{ {
id: 'since', id: 'since',
title: 'Since', title: 'Since',
@@ -380,6 +394,8 @@ Do not include any explanations, markdown formatting, or other text outside the
apiKey: { type: 'string', description: 'Personal access token' }, apiKey: { type: 'string', description: 'Personal access token' },
// Response operation params // Response operation params
pageSize: { type: 'number', description: 'Responses per page' }, pageSize: { type: 'number', description: 'Responses per page' },
before: { type: 'string', description: 'Cursor token for fetching the next page' },
after: { type: 'string', description: 'Cursor token for fetching newer results' },
since: { type: 'string', description: 'Start date filter' }, since: { type: 'string', description: 'Start date filter' },
until: { type: 'string', description: 'End date filter' }, until: { type: 'string', description: 'End date filter' },
completed: { type: 'string', description: 'Completion status filter' }, completed: { type: 'string', description: 'Completion status filter' },

View File

@@ -444,33 +444,36 @@ Return ONLY the search query - no explanations.`,
}, },
}, },
{ {
id: 'sortBy', id: 'filterType',
title: 'Sort By', title: 'Resource Type',
type: 'dropdown', type: 'dropdown',
options: [ options: [
{ label: 'Relevance', id: 'relevance' }, { label: 'Ticket', id: 'ticket' },
{ label: 'Created At', id: 'created_at' }, { label: 'User', id: 'user' },
{ label: 'Updated At', id: 'updated_at' }, { label: 'Organization', id: 'organization' },
{ label: 'Priority', id: 'priority' }, { label: 'Group', id: 'group' },
{ label: 'Status', id: 'status' },
{ label: 'Ticket Type', id: 'ticket_type' },
], ],
required: true,
condition: { condition: {
field: 'operation', field: 'operation',
value: ['search'], value: ['search'],
}, },
}, },
{ {
id: 'sortOrder', id: 'sort',
title: 'Sort Order', title: 'Sort',
type: 'dropdown', type: 'dropdown',
options: [ options: [
{ label: 'Ascending', id: 'asc' }, { label: 'Updated At (Asc)', id: 'updated_at' },
{ label: 'Descending', id: 'desc' }, { label: 'Updated At (Desc)', id: '-updated_at' },
{ label: 'ID (Asc)', id: 'id' },
{ label: 'ID (Desc)', id: '-id' },
{ label: 'Status (Asc)', id: 'status' },
{ label: 'Status (Desc)', id: '-status' },
], ],
condition: { condition: {
field: 'operation', field: 'operation',
value: ['search'], value: ['get_tickets'],
}, },
}, },
// Pagination fields // Pagination fields
@@ -492,20 +495,25 @@ Return ONLY the search query - no explanations.`,
}, },
}, },
{ {
id: 'page', id: 'pageAfter',
title: 'Page', title: 'Page After (Cursor)',
type: 'short-input', type: 'short-input',
placeholder: 'Page number', placeholder: 'Cursor from previous response (after_cursor)',
description: 'Cursor value from a previous response to fetch the next page of results',
condition: { condition: {
field: 'operation', field: 'operation',
value: [ value: ['get_tickets', 'get_users', 'get_organizations', 'search'],
'get_tickets', },
'get_users', },
'get_organizations', {
'search_users', id: 'page',
'autocomplete_organizations', title: 'Page Number',
'search', type: 'short-input',
], placeholder: 'Page number (default: 1)',
description: 'Page number for offset-based pagination',
condition: {
field: 'operation',
value: ['search_users', 'autocomplete_organizations'],
}, },
}, },
], ],
@@ -624,6 +632,7 @@ Return ONLY the search query - no explanations.`,
email: { type: 'string', description: 'Zendesk email address' }, email: { type: 'string', description: 'Zendesk email address' },
apiToken: { type: 'string', description: 'Zendesk API token' }, apiToken: { type: 'string', description: 'Zendesk API token' },
subdomain: { type: 'string', description: 'Zendesk subdomain' }, subdomain: { type: 'string', description: 'Zendesk subdomain' },
sort: { type: 'string', description: 'Sort field for ticket listing' },
}, },
outputs: { outputs: {
// Ticket operations - list // Ticket operations - list
@@ -665,8 +674,11 @@ Return ONLY the search query - no explanations.`,
type: 'boolean', type: 'boolean',
description: 'Deletion confirmation (delete_ticket, delete_user, delete_organization)', description: 'Deletion confirmation (delete_ticket, delete_user, delete_organization)',
}, },
// Pagination (shared across list operations) // Cursor-based pagination (shared across list operations)
paging: { type: 'json', description: 'Pagination information for list operations' }, paging: {
type: 'json',
description: 'Cursor-based pagination information (after_cursor, has_more)',
},
// Metadata (shared across all operations) // Metadata (shared across all operations)
metadata: { type: 'json', description: 'Operation metadata including operation type' }, metadata: { type: 'json', description: 'Operation metadata including operation type' },
}, },

View File

@@ -9,6 +9,7 @@ import {
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useId,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -170,6 +171,7 @@ const Combobox = memo(
}, },
ref ref
) => { ) => {
const listboxId = useId()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1) const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -513,6 +515,7 @@ const Combobox = memo(
role='combobox' role='combobox'
aria-expanded={open} aria-expanded={open}
aria-haspopup='listbox' aria-haspopup='listbox'
aria-controls={listboxId}
aria-disabled={disabled} aria-disabled={disabled}
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
className={cn( className={cn(
@@ -616,7 +619,7 @@ const Combobox = memo(
} }
}} }}
> >
<div ref={dropdownRef} role='listbox'> <div ref={dropdownRef} role='listbox' id={listboxId}>
{isLoading ? ( {isLoading ? (
<div className='flex items-center justify-center py-[14px]'> <div className='flex items-center justify-center py-[14px]'>
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-muted)]' /> <Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-muted)]' />

View File

@@ -15,10 +15,12 @@ export function ChevronDown(props: SVGProps<SVGSVGElement>) {
{...props} {...props}
> >
<path <path
fillRule='evenodd' d='M1 1L5 5L9 1'
clipRule='evenodd' stroke='currentColor'
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' strokeWidth='1.33'
fill='currentColor' strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/> />
</svg> </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>) { export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg {...props} width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'> <svg {...props} width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>

View File

@@ -27,12 +27,14 @@ const Alert = React.forwardRef<
Alert.displayName = 'Alert' Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => ( ({ className, children, ...props }, ref) => (
<h5 <h5
ref={ref} ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)} className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props} {...props}
/> >
{children}
</h5>
) )
) )
AlertTitle.displayName = 'AlertTitle' AlertTitle.displayName = 'AlertTitle'

View File

@@ -2,7 +2,7 @@
import { useState } from 'react' import { useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react' import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn' import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn'
import { Skeleton } from '@/components/ui' import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client' import { useSession } from '@/lib/auth/auth-client'
@@ -418,29 +418,29 @@ export function SSO() {
{/* Callback URL */} {/* Callback URL */}
<div className='flex flex-col gap-[8px]'> <div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'> <div className='flex items-center justify-between'>
Callback URL <span className='font-medium text-[13px] text-[var(--text-secondary)]'>
</span> Callback URL
<div className='relative'> </span>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{providerCallbackUrl}
</code>
</div>
<Button <Button
type='button' type='button'
variant='ghost' variant='ghost'
onClick={() => copyToClipboard(providerCallbackUrl)} onClick={() => copyToClipboard(providerCallbackUrl)}
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]' className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
> >
{copied ? ( {copied ? (
<Check className='h-[14px] w-[14px]' /> <Check className='h-[13px] w-[13px]' />
) : ( ) : (
<Copy className='h-[14px] w-[14px]' /> <Clipboard className='h-[13px] w-[13px]' />
)} )}
<span className='sr-only'>Copy callback URL</span> <span className='sr-only'>Copy callback URL</span>
</Button> </Button>
</div> </div>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{providerCallbackUrl}
</code>
</div>
<p className='text-[13px] text-[var(--text-muted)]'> <p className='text-[13px] text-[var(--text-muted)]'>
Configure this in your identity provider Configure this in your identity provider
</p> </p>
@@ -852,29 +852,29 @@ export function SSO() {
{/* Callback URL display */} {/* Callback URL display */}
<div className='flex flex-col gap-[8px]'> <div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'> <div className='flex items-center justify-between'>
Callback URL <span className='font-medium text-[13px] text-[var(--text-secondary)]'>
</span> Callback URL
<div className='relative'> </span>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{callbackUrl}
</code>
</div>
<Button <Button
type='button' type='button'
variant='ghost' variant='ghost'
onClick={() => copyToClipboard(callbackUrl)} onClick={() => copyToClipboard(callbackUrl)}
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]' className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
> >
{copied ? ( {copied ? (
<Check className='h-[14px] w-[14px]' /> <Check className='h-[13px] w-[13px]' />
) : ( ) : (
<Copy className='h-[14px] w-[14px]' /> <Clipboard className='h-[13px] w-[13px]' />
)} )}
<span className='sr-only'>Copy callback URL</span> <span className='sr-only'>Copy callback URL</span>
</Button> </Button>
</div> </div>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{callbackUrl}
</code>
</div>
<p className='text-[13px] text-[var(--text-muted)]'> <p className='text-[13px] text-[var(--text-muted)]'>
Configure this in your identity provider Configure this in your identity provider
</p> </p>

View File

@@ -9,8 +9,8 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
const brand = getBrandConfig() const brand = getBrandConfig()
const defaultTitle = brand.name 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 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 an open-source AI agent workflow builder for production workflows.` 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 { return {
title: { title: {
@@ -22,20 +22,21 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
authors: [{ name: brand.name }], authors: [{ name: brand.name }],
generator: 'Next.js', generator: 'Next.js',
keywords: [ keywords: [
'AI agent',
'AI agent builder',
'AI agent workflow',
'AI workflow automation',
'visual workflow editor',
'AI agents', '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', 'intelligent automation',
'AI tools', 'AI tools',
'workflow designer',
'artificial intelligence',
'business automation',
'AI agent workflows',
'visual programming',
], ],
referrer: 'origin-when-cross-origin', referrer: 'origin-when-cross-origin',
creator: brand.name, creator: brand.name,
@@ -130,11 +131,11 @@ export function generateStructuredData() {
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
name: 'Sim', name: 'Sim',
description: 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(), url: getBaseUrl(),
applicationCategory: 'BusinessApplication', applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser', operatingSystem: 'Web',
applicationSubCategory: 'AIWorkflowAutomation', applicationSubCategory: 'AIAgentPlatform',
areaServed: 'Worldwide', areaServed: 'Worldwide',
availableLanguage: ['en'], availableLanguage: ['en'],
offers: { offers: {
@@ -147,10 +148,13 @@ export function generateStructuredData() {
url: 'https://sim.ai', url: 'https://sim.ai',
}, },
featureList: [ featureList: [
'Visual AI Agent Builder', 'AI Agent Creation',
'Workflow Canvas Interface', 'Agentic Workflow Orchestration',
'AI Agent Automation', '1,000+ Integrations',
'Custom AI Workflows', 'LLM Orchestration',
'Knowledge Base Creation',
'Table Creation',
'Document Creation',
], ],
} }
} }

View File

@@ -16,26 +16,32 @@ export const mdxComponents: MDXRemoteProps['components'] = {
unoptimized unoptimized
/> />
), ),
h2: (props: any) => ( h2: ({ children, className, ...props }: any) => (
<h2 <h2
{...props} {...props}
style={{ fontSize: '30px', marginTop: '3rem', marginBottom: '1.5rem' }} style={{ fontSize: '30px', marginTop: '3rem', marginBottom: '1.5rem' }}
className={clsx('font-medium text-black leading-tight', props.className)} className={clsx('font-medium text-black leading-tight', className)}
/> >
{children}
</h2>
), ),
h3: (props: any) => ( h3: ({ children, className, ...props }: any) => (
<h3 <h3
{...props} {...props}
style={{ fontSize: '24px', marginTop: '1.5rem', marginBottom: '0.75rem' }} style={{ fontSize: '24px', marginTop: '1.5rem', marginBottom: '0.75rem' }}
className={clsx('font-medium leading-tight', props.className)} className={clsx('font-medium leading-tight', className)}
/> >
{children}
</h3>
), ),
h4: (props: any) => ( h4: ({ children, className, ...props }: any) => (
<h4 <h4
{...props} {...props}
style={{ fontSize: '19px', marginTop: '1.5rem', marginBottom: '0.75rem' }} style={{ fontSize: '19px', marginTop: '1.5rem', marginBottom: '0.75rem' }}
className={clsx('font-medium leading-tight', props.className)} className={clsx('font-medium leading-tight', className)}
/> >
{children}
</h4>
), ),
p: (props: any) => ( p: (props: any) => (
<p <p

View File

@@ -93,6 +93,7 @@ export const env = createEnv({
EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search
BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic") BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic")
BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed.
// Azure Configuration - Shared credentials with feature-specific models // Azure Configuration - Shared credentials with feature-specific models
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint

View File

@@ -1,7 +1,7 @@
/** /**
* Environment utility functions for consistent environment detection across the application * 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 * 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 * Is this the hosted version of the application
*/ */
export const isHosted = // export const isHosted =
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
export const isHosted = true
/** /**
* Is billing enforcement enabled * Is billing enforcement enabled
@@ -123,6 +124,35 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED)
*/ */
export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED)
/**
* Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var.
* Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com").
* Extracts the hostname in either case.
*/
function normalizeDomainEntry(entry: string): string {
const trimmed = entry.trim().toLowerCase()
if (!trimmed) return ''
if (trimmed.includes('://')) {
try {
return new URL(trimmed).hostname
} catch {
return trimmed
}
}
return trimmed
}
/**
* Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var.
* Returns null if not set (all domains allowed), or parsed array of lowercase hostnames.
* Accepts both bare hostnames and full URLs in the env var value.
*/
export function getAllowedMcpDomainsFromEnv(): string[] | null {
if (!env.ALLOWED_MCP_DOMAINS) return null
const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean)
return parsed.length > 0 ? parsed : null
}
/** /**
* Get cost multiplier based on environment * Get cost multiplier based on environment
*/ */

View File

@@ -0,0 +1,163 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetAllowedMcpDomainsFromEnv = vi.fn<() => string[] | null>()
const mockGetBaseUrl = vi.fn<() => string>()
vi.doMock('@/lib/core/config/feature-flags', () => ({
getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv,
}))
vi.doMock('@/lib/core/utils/urls', () => ({
getBaseUrl: mockGetBaseUrl,
}))
const { McpDomainNotAllowedError, isMcpDomainAllowed, validateMcpDomain } = await import(
'./domain-check'
)
describe('McpDomainNotAllowedError', () => {
it.concurrent('creates error with correct name and message', () => {
const error = new McpDomainNotAllowedError('evil.com')
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(McpDomainNotAllowedError)
expect(error.name).toBe('McpDomainNotAllowedError')
expect(error.message).toContain('evil.com')
})
})
describe('isMcpDomainAllowed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('when no allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
})
it('allows any URL', () => {
expect(isMcpDomainAllowed('https://any-server.com/mcp')).toBe(true)
})
it('allows undefined URL', () => {
expect(isMcpDomainAllowed(undefined)).toBe(true)
})
it('allows empty string URL', () => {
expect(isMcpDomainAllowed('')).toBe(true)
})
})
describe('when allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com'])
mockGetBaseUrl.mockReturnValue('https://platform.example.com')
})
it('allows URLs on the allowlist', () => {
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true)
})
it('rejects URLs not on the allowlist', () => {
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
})
it('rejects undefined URL (fail-closed)', () => {
expect(isMcpDomainAllowed(undefined)).toBe(false)
})
it('rejects empty string URL (fail-closed)', () => {
expect(isMcpDomainAllowed('')).toBe(false)
})
it('rejects malformed URLs', () => {
expect(isMcpDomainAllowed('not-a-url')).toBe(false)
})
it('matches case-insensitively', () => {
expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true)
})
it('always allows the platform hostname', () => {
expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true)
})
it('allows platform hostname even when not in the allowlist', () => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['other.com'])
expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true)
})
})
describe('when getBaseUrl is not configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com'])
mockGetBaseUrl.mockImplementation(() => {
throw new Error('Not configured')
})
})
it('still allows URLs on the allowlist', () => {
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
})
it('still rejects URLs not on the allowlist', () => {
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
})
})
})
describe('validateMcpDomain', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('when no allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
})
it('does not throw for any URL', () => {
expect(() => validateMcpDomain('https://any-server.com/mcp')).not.toThrow()
})
it('does not throw for undefined URL', () => {
expect(() => validateMcpDomain(undefined)).not.toThrow()
})
})
describe('when allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com'])
mockGetBaseUrl.mockReturnValue('https://platform.example.com')
})
it('does not throw for allowed URLs', () => {
expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow()
})
it('throws McpDomainNotAllowedError for disallowed URLs', () => {
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError)
})
it('throws for undefined URL (fail-closed)', () => {
expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError)
})
it('throws for malformed URLs', () => {
expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError)
})
it('includes the rejected domain in the error message', () => {
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/)
})
it('does not throw for platform hostname', () => {
expect(() => validateMcpDomain('https://platform.example.com/mcp')).not.toThrow()
})
})
})

View File

@@ -0,0 +1,69 @@
import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
export class McpDomainNotAllowedError extends Error {
constructor(domain: string) {
super(`MCP server domain "${domain}" is not allowed by the server's ALLOWED_MCP_DOMAINS policy`)
this.name = 'McpDomainNotAllowedError'
}
}
let cachedPlatformHostname: string | null = null
/**
* Returns the platform's own hostname (from getBaseUrl), lazy-cached.
* Always lowercase. Returns null if the base URL is not configured or invalid.
*/
function getPlatformHostname(): string | null {
if (cachedPlatformHostname !== null) return cachedPlatformHostname
try {
cachedPlatformHostname = new URL(getBaseUrl()).hostname.toLowerCase()
} catch {
return null
}
return cachedPlatformHostname
}
/**
* Core domain check. Returns null if the URL is allowed, or the hostname/url
* string to use in the rejection error.
*/
function checkMcpDomain(url: string): string | null {
const allowedDomains = getAllowedMcpDomainsFromEnv()
if (allowedDomains === null) return null
try {
const hostname = new URL(url).hostname.toLowerCase()
if (hostname === getPlatformHostname()) return null
return allowedDomains.includes(hostname) ? null : hostname
} catch {
return url
}
}
/**
* Returns true if the URL's domain is allowed (or no restriction is configured).
* The platform's own hostname (from getBaseUrl) is always allowed.
*/
export function isMcpDomainAllowed(url: string | undefined): boolean {
if (!url) {
return getAllowedMcpDomainsFromEnv() === null
}
return checkMcpDomain(url) === null
}
/**
* Throws McpDomainNotAllowedError if the URL's domain is not in the allowlist.
* The platform's own hostname (from getBaseUrl) is always allowed.
*/
export function validateMcpDomain(url: string | undefined): void {
if (!url) {
if (getAllowedMcpDomainsFromEnv() !== null) {
throw new McpDomainNotAllowedError('(empty)')
}
return
}
const rejected = checkMcpDomain(url)
if (rejected !== null) {
throw new McpDomainNotAllowedError(rejected)
}
}

View File

@@ -10,6 +10,7 @@ import { isTest } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { McpClient } from '@/lib/mcp/client' import { McpClient } from '@/lib/mcp/client'
import { mcpConnectionManager } from '@/lib/mcp/connection-manager' import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
import { isMcpDomainAllowed } from '@/lib/mcp/domain-check'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import { import {
createMcpCacheAdapter, createMcpCacheAdapter,
@@ -93,6 +94,10 @@ class McpService {
return null return null
} }
if (!isMcpDomainAllowed(server.url || undefined)) {
return null
}
return { return {
id: server.id, id: server.id,
name: server.name, name: server.name,
@@ -123,19 +128,21 @@ class McpService {
.from(mcpServers) .from(mcpServers)
.where(and(...whereConditions)) .where(and(...whereConditions))
return servers.map((server) => ({ return servers
id: server.id, .map((server) => ({
name: server.name, id: server.id,
description: server.description || undefined, name: server.name,
transport: server.transport as McpTransport, description: server.description || undefined,
url: server.url || undefined, transport: server.transport as McpTransport,
headers: (server.headers as Record<string, string>) || {}, url: server.url || undefined,
timeout: server.timeout || 30000, headers: (server.headers as Record<string, string>) || {},
retries: server.retries || 3, timeout: server.timeout || 30000,
enabled: server.enabled, retries: server.retries || 3,
createdAt: server.createdAt.toISOString(), enabled: server.enabled,
updatedAt: server.updatedAt.toISOString(), createdAt: server.createdAt.toISOString(),
})) updatedAt: server.updatedAt.toISOString(),
}))
.filter((config) => isMcpDomainAllowed(config.url))
} }
/** /**

View File

@@ -107,5 +107,3 @@ if (typeof process !== 'undefined') {
logger.info(`S3 copilot bucket: ${env.S3_COPILOT_BUCKET_NAME}`) logger.info(`S3 copilot bucket: ${env.S3_COPILOT_BUCKET_NAME}`)
} }
} }
export default ensureUploadsDirectory

View File

@@ -324,6 +324,15 @@ const nextConfig: NextConfig = {
) )
} }
// Beluga campaign short link tracking
if (isHosted) {
redirects.push({
source: '/r/:shortCode',
destination: 'https://go.trybeluga.ai/:shortCode',
permanent: false,
})
}
return redirects return redirects
}, },
} }

View File

@@ -14,6 +14,7 @@ import {
supportsNativeStructuredOutputs, supportsNativeStructuredOutputs,
} from '@/providers/models' } from '@/providers/models'
import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -842,15 +843,11 @@ export async function executeAnthropicProviderRequest(
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
} }
@@ -1299,14 +1296,10 @@ export async function executeAnthropicProviderRequest(
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
} }

View File

@@ -30,6 +30,7 @@ import type {
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -251,7 +252,7 @@ async function executeChatCompletionsRequest(
output: currentResponse.usage?.completion_tokens || 0, output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0, total: currentResponse.usage?.total_tokens || 0,
} }
const toolCalls: (FunctionCallResponse & { success: boolean })[] = [] const toolCalls: FunctionCallResponse[] = []
const toolResults: Record<string, unknown>[] = [] const toolResults: Record<string, unknown>[] = []
const currentMessages = [...allMessages] const currentMessages = [...allMessages]
let iterationCount = 0 let iterationCount = 0
@@ -577,15 +578,11 @@ async function executeChatCompletionsRequest(
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore - Adding timing property to the error
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
} }

View File

@@ -22,11 +22,13 @@ import {
} from '@/providers/bedrock/utils' } from '@/providers/bedrock/utils'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { import type {
FunctionCallResponse,
ProviderConfig, ProviderConfig,
ProviderRequest, ProviderRequest,
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -419,8 +421,8 @@ export const bedrockProvider: ProviderConfig = {
pricing: initialCost.pricing, pricing: initialCost.pricing,
} }
const toolCalls: any[] = [] const toolCalls: FunctionCallResponse[] = []
const toolResults: any[] = [] const toolResults: Record<string, unknown>[] = []
const currentMessages = [...messages] const currentMessages = [...messages]
let iterationCount = 0 let iterationCount = 0
let hasUsedForcedTool = false let hasUsedForcedTool = false
@@ -561,7 +563,7 @@ export const bedrockProvider: ProviderConfig = {
let resultContent: any let resultContent: any
if (result.success) { if (result.success) {
toolResults.push(result.output) toolResults.push(result.output!)
resultContent = result.output resultContent = result.output
} else { } else {
resultContent = { resultContent = {
@@ -903,15 +905,11 @@ export const bedrockProvider: ProviderConfig = {
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

View File

@@ -11,6 +11,7 @@ import type {
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -539,15 +540,11 @@ export const cerebrasProvider: ProviderConfig = {
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore - Adding timing property to error for debugging
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

View File

@@ -10,6 +10,7 @@ import type {
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -538,15 +539,11 @@ export const deepseekProvider: ProviderConfig = {
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

View File

@@ -10,6 +10,7 @@ import type {
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -496,15 +497,11 @@ export const groqProvider: ProviderConfig = {
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

View File

@@ -11,6 +11,7 @@ import type {
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -551,15 +552,11 @@ export const mistralProvider: ProviderConfig = {
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore - Adding timing property to error for debugging
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

View File

@@ -12,6 +12,7 @@ import type {
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { calculateCost, prepareToolExecution } from '@/providers/utils' import { calculateCost, prepareToolExecution } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers' import { useProvidersStore } from '@/stores/providers'
import { executeTool } from '@/tools' import { executeTool } from '@/tools'
@@ -554,15 +555,11 @@ export const ollamaProvider: ProviderConfig = {
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

View File

@@ -3,6 +3,7 @@ import type OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -806,14 +807,10 @@ export async function executeResponsesProviderRequest(
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore - Adding timing property to the error
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
} }

View File

@@ -10,11 +10,14 @@ import {
supportsNativeStructuredOutputs, supportsNativeStructuredOutputs,
} from '@/providers/openrouter/utils' } from '@/providers/openrouter/utils'
import type { import type {
FunctionCallResponse,
Message,
ProviderConfig, ProviderConfig,
ProviderRequest, ProviderRequest,
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
generateSchemaInstructions, generateSchemaInstructions,
@@ -90,7 +93,7 @@ export const openRouterProvider: ProviderConfig = {
stream: !!request.stream, stream: !!request.stream,
}) })
const allMessages = [] as any[] const allMessages: Message[] = []
if (request.systemPrompt) { if (request.systemPrompt) {
allMessages.push({ role: 'system', content: request.systemPrompt }) allMessages.push({ role: 'system', content: request.systemPrompt })
@@ -237,8 +240,8 @@ export const openRouterProvider: ProviderConfig = {
output: currentResponse.usage?.completion_tokens || 0, output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0, total: currentResponse.usage?.total_tokens || 0,
} }
const toolCalls = [] as any[] const toolCalls: FunctionCallResponse[] = []
const toolResults = [] as any[] const toolResults: Record<string, unknown>[] = []
const currentMessages = [...allMessages] const currentMessages = [...allMessages]
let iterationCount = 0 let iterationCount = 0
let modelTime = firstResponseTime let modelTime = firstResponseTime
@@ -352,7 +355,7 @@ export const openRouterProvider: ProviderConfig = {
let resultContent: any let resultContent: any
if (result.success) { if (result.success) {
toolResults.push(result.output) toolResults.push(result.output!)
resultContent = result.output resultContent = result.output
} else { } else {
resultContent = { resultContent = {
@@ -593,14 +596,11 @@ export const openRouterProvider: ProviderConfig = {
} }
logger.error('Error in OpenRouter request:', errorDetails) logger.error('Error in OpenRouter request:', errorDetails)
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

View File

@@ -59,6 +59,7 @@ export interface FunctionCallResponse {
result?: Record<string, any> result?: Record<string, any>
output?: Record<string, any> output?: Record<string, any>
input?: Record<string, any> input?: Record<string, any>
success?: boolean
} }
export interface TimeSegment { export interface TimeSegment {
@@ -177,4 +178,21 @@ export interface ProviderRequest {
previousInteractionId?: string previousInteractionId?: string
} }
/**
* Typed error class for provider failures that includes timing information.
*/
export class ProviderError extends Error {
timing: {
startTime: string
endTime: string
duration: number
}
constructor(message: string, timing: { startTime: string; endTime: string; duration: number }) {
super(message)
this.name = 'ProviderError'
this.timing = timing
}
}
export const providers: Record<string, ProviderConfig> = {} export const providers: Record<string, ProviderConfig> = {}

View File

@@ -6,11 +6,13 @@ import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { import type {
Message,
ProviderConfig, ProviderConfig,
ProviderRequest, ProviderRequest,
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -98,7 +100,7 @@ export const vllmProvider: ProviderConfig = {
baseURL: `${baseUrl}/v1`, baseURL: `${baseUrl}/v1`,
}) })
const allMessages = [] as any[] const allMessages: Message[] = []
if (request.systemPrompt) { if (request.systemPrompt) {
allMessages.push({ allMessages.push({
@@ -635,23 +637,11 @@ export const vllmProvider: ProviderConfig = {
duration: totalDuration, duration: totalDuration,
}) })
const enhancedError = new Error(errorMessage) throw new ProviderError(errorMessage, {
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
if (errorType) {
// @ts-ignore
enhancedError.vllmErrorType = errorType
}
if (errorCode) {
// @ts-ignore
enhancedError.vllmErrorCode = errorCode
}
throw enhancedError
} }
}, },
} }

View File

@@ -5,11 +5,13 @@ import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { import type {
Message,
ProviderConfig, ProviderConfig,
ProviderRequest, ProviderRequest,
ProviderResponse, ProviderResponse,
TimeSegment, TimeSegment,
} from '@/providers/types' } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { import {
calculateCost, calculateCost,
prepareToolExecution, prepareToolExecution,
@@ -52,7 +54,7 @@ export const xAIProvider: ProviderConfig = {
streaming: !!request.stream, streaming: !!request.stream,
}) })
const allMessages: any[] = [] const allMessages: Message[] = []
if (request.systemPrompt) { if (request.systemPrompt) {
allMessages.push({ allMessages.push({
@@ -587,15 +589,11 @@ export const xAIProvider: ProviderConfig = {
hasResponseFormat: !!request.responseFormat, hasResponseFormat: !!request.responseFormat,
}) })
const enhancedError = new Error(error instanceof Error ? error.message : String(error)) throw new ProviderError(error instanceof Error ? error.message : String(error), {
// @ts-ignore - Adding timing property to error for debugging
enhancedError.timing = {
startTime: providerStartTimeISO, startTime: providerStartTimeISO,
endTime: providerEndTimeISO, endTime: providerEndTimeISO,
duration: totalDuration, duration: totalDuration,
} })
throw enhancedError
} }
}, },
} }

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

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