Compare commits
18 Commits
fix/build
...
feat/landi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504357a305 | ||
|
|
620a7a798e | ||
|
|
25763efcd6 | ||
|
|
8bc383267b | ||
|
|
04286fc16b | ||
|
|
c52f78c840 | ||
|
|
e318bf2e65 | ||
|
|
4913799a27 | ||
|
|
ccb4f5956d | ||
|
|
2a6d4fcb96 | ||
|
|
42020c3ae2 | ||
|
|
a98463a486 | ||
|
|
765a481864 | ||
|
|
a1400caea0 | ||
|
|
2fc2e12cb2 | ||
|
|
3fa4bb4c12 | ||
|
|
1b8d666c93 | ||
|
|
71942cb53c |
26
.cursor/rules/landing-seo-geo.mdc
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: SEO and GEO guidelines for the landing page
|
||||
globs: ["apps/sim/app/(home)/**/*.tsx"]
|
||||
---
|
||||
|
||||
# Landing Page — SEO / GEO
|
||||
|
||||
## SEO
|
||||
|
||||
- One `<h1>` per page, in Hero only — never add another.
|
||||
- Strict heading hierarchy: H1 (Hero) → H2 (section titles) → H3 (feature names).
|
||||
- Every section: `<section id="…" aria-labelledby="…-heading">`.
|
||||
- Decorative/animated elements: `aria-hidden="true"`.
|
||||
- All internal routes use Next.js `<Link>` (crawlable). External links get `rel="noopener noreferrer"`.
|
||||
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element).
|
||||
- Navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
|
||||
- Feature lists must stay in sync with `WebApplication.featureList` in `structured-data.tsx`.
|
||||
|
||||
## GEO (Generative Engine Optimisation)
|
||||
|
||||
- **Answer-first pattern**: each section's H2 + subtitle should directly answer a user question (e.g. "What is Sim?", "How fast can I deploy?").
|
||||
- **Atomic answer blocks**: each feature / template card should be independently extractable by an AI summariser.
|
||||
- **Entity consistency**: always write "Sim" by name — never "the platform" or "our tool".
|
||||
- **Keyword density**: first 150 visible chars of Hero must name "Sim", "AI agents", "agentic workflows".
|
||||
- **sr-only summaries**: Hero and Templates each have a `<p className="sr-only">` (~50 words) as an atomic product/catalog summary for AI citation.
|
||||
- **Specific numbers**: prefer concrete figures ("1,000+ integrations", "15+ AI providers") over vague claims.
|
||||
2
.github/workflows/images.yml
vendored
@@ -146,7 +146,7 @@ jobs:
|
||||
|
||||
create-ghcr-manifests:
|
||||
name: Create GHCR Manifests
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
needs: [build-amd64, build-ghcr-arm64]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
strategy:
|
||||
|
||||
2
.github/workflows/test-build.yml
vendored
@@ -110,7 +110,7 @@ jobs:
|
||||
RESEND_API_KEY: 'dummy_key_for_ci_only'
|
||||
AWS_REGION: 'us-west-2'
|
||||
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
|
||||
run: bun run build
|
||||
run: bunx turbo run build --filter=sim
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Build and deploy AI agent workflows in minutes.</p>
|
||||
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
|
||||
@@ -264,15 +264,17 @@ export async function generateMetadata(props: {
|
||||
return {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
keywords: [
|
||||
'AI workflow builder',
|
||||
'visual workflow editor',
|
||||
'AI automation',
|
||||
'workflow automation',
|
||||
'AI agents',
|
||||
'no-code AI',
|
||||
'drag and drop workflows',
|
||||
'agentic workforce',
|
||||
'AI agent platform',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI automation',
|
||||
'knowledge base',
|
||||
'AI integrations',
|
||||
data.title?.toLowerCase().split(' '),
|
||||
]
|
||||
.flat()
|
||||
@@ -282,7 +284,8 @@ export async function generateMetadata(props: {
|
||||
openGraph: {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
url: fullUrl,
|
||||
siteName: 'Sim Documentation',
|
||||
type: 'article',
|
||||
@@ -303,7 +306,8 @@ export async function generateMetadata(props: {
|
||||
card: 'summary_large_image',
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
images: [ogImageUrl],
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
'@type': 'WebSite',
|
||||
name: 'Sim Documentation',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI Agent Workflows.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
url: 'https://docs.sim.ai',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
|
||||
@@ -7,26 +7,27 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
default: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
template: '%s',
|
||||
},
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
keywords: [
|
||||
'AI workflow builder',
|
||||
'visual workflow editor',
|
||||
'AI automation',
|
||||
'workflow automation',
|
||||
'AI agents',
|
||||
'no-code AI',
|
||||
'drag and drop workflows',
|
||||
'agentic workforce',
|
||||
'AI agent platform',
|
||||
'open-source AI agents',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI integrations',
|
||||
'workflow canvas',
|
||||
'AI Agent Workflow Builder',
|
||||
'workflow orchestration',
|
||||
'agent builder',
|
||||
'AI workflow automation',
|
||||
'visual programming',
|
||||
'knowledge base',
|
||||
'AI automation',
|
||||
'workflow builder',
|
||||
'AI workflow orchestration',
|
||||
'enterprise AI',
|
||||
'AI agent deployment',
|
||||
'intelligent automation',
|
||||
'AI tools',
|
||||
],
|
||||
authors: [{ name: 'Sim Team', url: 'https://sim.ai' }],
|
||||
creator: 'Sim',
|
||||
@@ -53,9 +54,9 @@ export const metadata = {
|
||||
alternateLocale: ['es_ES', 'fr_FR', 'de_DE', 'ja_JP', 'zh_CN'],
|
||||
url: 'https://docs.sim.ai',
|
||||
siteName: 'Sim Documentation',
|
||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation',
|
||||
@@ -67,9 +68,9 @@ export const metadata = {
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation'],
|
||||
|
||||
@@ -37,9 +37,9 @@ export async function GET() {
|
||||
|
||||
const manifest = `# Sim Documentation
|
||||
|
||||
> Visual Workflow Builder for AI Applications
|
||||
> The open-source platform to build AI agents and run your agentic workforce.
|
||||
|
||||
Sim is a visual workflow builder for AI applications that lets you build AI agent workflows visually. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders.
|
||||
|
||||
## Documentation Overview
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ export function TOCFooter() {
|
||||
<div className='text-balance font-semibold text-base leading-tight'>
|
||||
Start building today
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Trusted by over 60,000 builders.</div>
|
||||
<div className='text-muted-foreground'>Trusted by over 100,000 builders.</div>
|
||||
<div className='text-muted-foreground'>
|
||||
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
|
||||
The open-source platform to build AI agents and run your agentic workforce.
|
||||
</div>
|
||||
<Link
|
||||
href='https://sim.ai/signup'
|
||||
|
||||
@@ -5819,3 +5819,15 @@ export function RedisIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HexIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1450.3 600'>
|
||||
<path
|
||||
fill='#5F509D'
|
||||
fillRule='evenodd'
|
||||
d='m250.11,0v199.49h-50V0H0v600h200.11v-300.69h50v300.69h200.18V0h-200.18Zm249.9,0v600h450.29v-250.23h-200.2v149h-50v-199.46h250.2V0h-450.29Zm200.09,199.49v-99.49h50v99.49h-50Zm550.02,0V0h200.18v150l-100,100.09,100,100.09v249.82h-200.18v-300.69h-50v300.69h-200.11v-249.82l100.11-100.09-100.11-100.09V0h200.11v199.49h50Z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function StructuredData({
|
||||
name: 'Sim Documentation',
|
||||
url: baseUrl,
|
||||
description:
|
||||
'Comprehensive documentation for Sim visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
@@ -104,7 +104,7 @@ export function StructuredData({
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Any',
|
||||
description:
|
||||
'Visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
url: baseUrl,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
@@ -115,12 +115,13 @@ export function StructuredData({
|
||||
category: 'Developer Tools',
|
||||
},
|
||||
featureList: [
|
||||
'Visual workflow builder with drag-and-drop interface',
|
||||
'AI agent creation and automation',
|
||||
'80+ built-in integrations',
|
||||
'Real-time team collaboration',
|
||||
'Multiple deployment options',
|
||||
'Custom integrations via MCP protocol',
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
GrafanaIcon,
|
||||
GrainIcon,
|
||||
GreptileIcon,
|
||||
HexIcon,
|
||||
HubspotIcon,
|
||||
HuggingFaceIcon,
|
||||
HunterIOIcon,
|
||||
@@ -196,6 +197,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
grafana: GrafanaIcon,
|
||||
grain: GrainIcon,
|
||||
greptile: GreptileIcon,
|
||||
hex: HexIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
|
||||
459
apps/docs/content/docs/en/tools/hex.mdx
Normal file
@@ -0,0 +1,459 @@
|
||||
---
|
||||
title: Hex
|
||||
description: Run and manage Hex projects
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="hex"
|
||||
color="#F5E6FF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Hex](https://hex.tech/) is a collaborative platform for analytics and data science that allows you to build, run, and share interactive data projects and notebooks. Hex lets teams work together on data exploration, transformation, and visualization, making it easy to turn analysis into shareable insights.
|
||||
|
||||
With Hex, you can:
|
||||
|
||||
- **Create and run powerful notebooks**: Blend SQL, Python, and visualizations in a single, interactive workspace.
|
||||
- **Collaborate and share**: Work together with teammates in real time and publish interactive data apps for broader audiences.
|
||||
- **Automate and orchestrate workflows**: Schedule notebook runs, parameterize runs with inputs, and automate data tasks.
|
||||
- **Visualize and communicate results**: Turn analysis results into dashboards or interactive apps that anyone can use.
|
||||
- **Integrate with your data stack**: Connect easily to data warehouses, APIs, and other sources.
|
||||
|
||||
The Sim Hex integration allows your AI agents or workflows to:
|
||||
|
||||
- List, get, and manage Hex projects directly from Sim.
|
||||
- Trigger and monitor notebook runs, check their statuses, or cancel them as part of larger automation flows.
|
||||
- Retrieve run results and use them within Sim-powered processes and decision-making.
|
||||
- Leverage Hex’s interactive analytics capabilities right inside your automated Sim workflows.
|
||||
|
||||
Whether you’re empowering analysts, automating reporting, or embedding actionable data into your processes, Hex and Sim provide a seamless way to operationalize analytics and bring data-driven insights to your team.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `hex_cancel_run`
|
||||
|
||||
Cancel an active Hex project run.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `projectId` | string | Yes | The UUID of the Hex project |
|
||||
| `runId` | string | Yes | The UUID of the run to cancel |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the run was successfully cancelled |
|
||||
| `projectId` | string | Project UUID |
|
||||
| `runId` | string | Run UUID that was cancelled |
|
||||
|
||||
### `hex_create_collection`
|
||||
|
||||
Create a new collection in the Hex workspace to organize projects.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `name` | string | Yes | Name for the new collection |
|
||||
| `description` | string | No | Optional description for the collection |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Newly created collection UUID |
|
||||
| `name` | string | Collection name |
|
||||
| `description` | string | Collection description |
|
||||
| `creator` | object | Collection creator |
|
||||
| ↳ `email` | string | Creator email |
|
||||
| ↳ `id` | string | Creator UUID |
|
||||
|
||||
### `hex_get_collection`
|
||||
|
||||
Retrieve details for a specific Hex collection by its ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `collectionId` | string | Yes | The UUID of the collection |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Collection UUID |
|
||||
| `name` | string | Collection name |
|
||||
| `description` | string | Collection description |
|
||||
| `creator` | object | Collection creator |
|
||||
| ↳ `email` | string | Creator email |
|
||||
| ↳ `id` | string | Creator UUID |
|
||||
|
||||
### `hex_get_data_connection`
|
||||
|
||||
Retrieve details for a specific data connection including type, description, and configuration flags.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `dataConnectionId` | string | Yes | The UUID of the data connection |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Connection UUID |
|
||||
| `name` | string | Connection name |
|
||||
| `type` | string | Connection type \(e.g., snowflake, postgres, bigquery\) |
|
||||
| `description` | string | Connection description |
|
||||
| `connectViaSsh` | boolean | Whether SSH tunneling is enabled |
|
||||
| `includeMagic` | boolean | Whether Magic AI features are enabled |
|
||||
| `allowWritebackCells` | boolean | Whether writeback cells are allowed |
|
||||
|
||||
### `hex_get_group`
|
||||
|
||||
Retrieve details for a specific Hex group.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `groupId` | string | Yes | The UUID of the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Group UUID |
|
||||
| `name` | string | Group name |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
|
||||
### `hex_get_project`
|
||||
|
||||
Get metadata and details for a specific Hex project by its ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `projectId` | string | Yes | The UUID of the Hex project |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Project UUID |
|
||||
| `title` | string | Project title |
|
||||
| `description` | string | Project description |
|
||||
| `status` | object | Project status |
|
||||
| ↳ `name` | string | Status name \(e.g., PUBLISHED, DRAFT\) |
|
||||
| `type` | string | Project type \(PROJECT or COMPONENT\) |
|
||||
| `creator` | object | Project creator |
|
||||
| ↳ `email` | string | Creator email |
|
||||
| `owner` | object | Project owner |
|
||||
| ↳ `email` | string | Owner email |
|
||||
| `categories` | array | Project categories |
|
||||
| ↳ `name` | string | Category name |
|
||||
| ↳ `description` | string | Category description |
|
||||
| `lastEditedAt` | string | ISO 8601 last edited timestamp |
|
||||
| `lastPublishedAt` | string | ISO 8601 last published timestamp |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `archivedAt` | string | ISO 8601 archived timestamp |
|
||||
| `trashedAt` | string | ISO 8601 trashed timestamp |
|
||||
|
||||
### `hex_get_project_runs`
|
||||
|
||||
Retrieve API-triggered runs for a Hex project with optional filtering by status and pagination.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `projectId` | string | Yes | The UUID of the Hex project |
|
||||
| `limit` | number | No | Maximum number of runs to return \(1-100, default: 25\) |
|
||||
| `offset` | number | No | Offset for paginated results \(default: 0\) |
|
||||
| `statusFilter` | string | No | Filter by run status: PENDING, RUNNING, ERRORED, COMPLETED, KILLED, UNABLE_TO_ALLOCATE_KERNEL |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `runs` | array | List of project runs |
|
||||
| ↳ `projectId` | string | Project UUID |
|
||||
| ↳ `runId` | string | Run UUID |
|
||||
| ↳ `runUrl` | string | URL to view the run |
|
||||
| ↳ `status` | string | Run status \(PENDING, RUNNING, COMPLETED, ERRORED, KILLED, UNABLE_TO_ALLOCATE_KERNEL\) |
|
||||
| ↳ `startTime` | string | Run start time |
|
||||
| ↳ `endTime` | string | Run end time |
|
||||
| ↳ `elapsedTime` | number | Elapsed time in seconds |
|
||||
| ↳ `traceId` | string | Trace ID |
|
||||
| ↳ `projectVersion` | number | Project version number |
|
||||
| `total` | number | Total number of runs returned |
|
||||
| `traceId` | string | Top-level trace ID |
|
||||
|
||||
### `hex_get_queried_tables`
|
||||
|
||||
Return the warehouse tables queried by a Hex project, including data connection and table names.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `projectId` | string | Yes | The UUID of the Hex project |
|
||||
| `limit` | number | No | Maximum number of tables to return \(1-100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tables` | array | List of warehouse tables queried by the project |
|
||||
| ↳ `dataConnectionId` | string | Data connection UUID |
|
||||
| ↳ `dataConnectionName` | string | Data connection name |
|
||||
| ↳ `tableName` | string | Table name |
|
||||
| `total` | number | Total number of tables returned |
|
||||
|
||||
### `hex_get_run_status`
|
||||
|
||||
Check the status of a Hex project run by its run ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `projectId` | string | Yes | The UUID of the Hex project |
|
||||
| `runId` | string | Yes | The UUID of the run to check |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `projectId` | string | Project UUID |
|
||||
| `runId` | string | Run UUID |
|
||||
| `runUrl` | string | URL to view the run |
|
||||
| `status` | string | Run status \(PENDING, RUNNING, COMPLETED, ERRORED, KILLED, UNABLE_TO_ALLOCATE_KERNEL\) |
|
||||
| `startTime` | string | ISO 8601 run start time |
|
||||
| `endTime` | string | ISO 8601 run end time |
|
||||
| `elapsedTime` | number | Elapsed time in seconds |
|
||||
| `traceId` | string | Trace ID for debugging |
|
||||
| `projectVersion` | number | Project version number |
|
||||
|
||||
### `hex_list_collections`
|
||||
|
||||
List all collections in the Hex workspace.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `limit` | number | No | Maximum number of collections to return \(1-500, default: 25\) |
|
||||
| `sortBy` | string | No | Sort by field: NAME |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `collections` | array | List of collections |
|
||||
| ↳ `id` | string | Collection UUID |
|
||||
| ↳ `name` | string | Collection name |
|
||||
| ↳ `description` | string | Collection description |
|
||||
| ↳ `creator` | object | Collection creator |
|
||||
| ↳ `email` | string | Creator email |
|
||||
| ↳ `id` | string | Creator UUID |
|
||||
| `total` | number | Total number of collections returned |
|
||||
|
||||
### `hex_list_data_connections`
|
||||
|
||||
List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, BigQuery).
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `limit` | number | No | Maximum number of connections to return \(1-500, default: 25\) |
|
||||
| `sortBy` | string | No | Sort by field: CREATED_AT or NAME |
|
||||
| `sortDirection` | string | No | Sort direction: ASC or DESC |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `connections` | array | List of data connections |
|
||||
| ↳ `id` | string | Connection UUID |
|
||||
| ↳ `name` | string | Connection name |
|
||||
| ↳ `type` | string | Connection type \(e.g., athena, bigquery, databricks, postgres, redshift, snowflake\) |
|
||||
| ↳ `description` | string | Connection description |
|
||||
| ↳ `connectViaSsh` | boolean | Whether SSH tunneling is enabled |
|
||||
| ↳ `includeMagic` | boolean | Whether Magic AI features are enabled |
|
||||
| ↳ `allowWritebackCells` | boolean | Whether writeback cells are allowed |
|
||||
| `total` | number | Total number of connections returned |
|
||||
|
||||
### `hex_list_groups`
|
||||
|
||||
List all groups in the Hex workspace with optional sorting.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `limit` | number | No | Maximum number of groups to return \(1-500, default: 25\) |
|
||||
| `sortBy` | string | No | Sort by field: CREATED_AT or NAME |
|
||||
| `sortDirection` | string | No | Sort direction: ASC or DESC |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groups` | array | List of workspace groups |
|
||||
| ↳ `id` | string | Group UUID |
|
||||
| ↳ `name` | string | Group name |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| `total` | number | Total number of groups returned |
|
||||
|
||||
### `hex_list_projects`
|
||||
|
||||
List all projects in your Hex workspace with optional filtering by status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `limit` | number | No | Maximum number of projects to return \(1-100\) |
|
||||
| `includeArchived` | boolean | No | Include archived projects in results |
|
||||
| `statusFilter` | string | No | Filter by status: PUBLISHED, DRAFT, or ALL |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `projects` | array | List of Hex projects |
|
||||
| ↳ `id` | string | Project UUID |
|
||||
| ↳ `title` | string | Project title |
|
||||
| ↳ `description` | string | Project description |
|
||||
| ↳ `status` | object | Project status |
|
||||
| ↳ `name` | string | Status name \(e.g., PUBLISHED, DRAFT\) |
|
||||
| ↳ `type` | string | Project type \(PROJECT or COMPONENT\) |
|
||||
| ↳ `creator` | object | Project creator |
|
||||
| ↳ `email` | string | Creator email |
|
||||
| ↳ `owner` | object | Project owner |
|
||||
| ↳ `email` | string | Owner email |
|
||||
| ↳ `lastEditedAt` | string | Last edited timestamp |
|
||||
| ↳ `lastPublishedAt` | string | Last published timestamp |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `archivedAt` | string | Archived timestamp |
|
||||
| `total` | number | Total number of projects returned |
|
||||
|
||||
### `hex_list_users`
|
||||
|
||||
List all users in the Hex workspace with optional filtering and sorting.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `limit` | number | No | Maximum number of users to return \(1-100, default: 25\) |
|
||||
| `sortBy` | string | No | Sort by field: NAME or EMAIL |
|
||||
| `sortDirection` | string | No | Sort direction: ASC or DESC |
|
||||
| `groupId` | string | No | Filter users by group UUID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `users` | array | List of workspace users |
|
||||
| ↳ `id` | string | User UUID |
|
||||
| ↳ `name` | string | User name |
|
||||
| ↳ `email` | string | User email |
|
||||
| ↳ `role` | string | User role \(ADMIN, MANAGER, EDITOR, EXPLORER, MEMBER, GUEST, EMBEDDED_USER, ANONYMOUS\) |
|
||||
| `total` | number | Total number of users returned |
|
||||
|
||||
### `hex_run_project`
|
||||
|
||||
Execute a published Hex project. Optionally pass input parameters and control caching behavior.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `projectId` | string | Yes | The UUID of the Hex project to run |
|
||||
| `inputParams` | json | No | JSON object of input parameters for the project \(e.g., \{"date": "2024-01-01"\}\) |
|
||||
| `dryRun` | boolean | No | If true, perform a dry run without executing the project |
|
||||
| `updateCache` | boolean | No | \(Deprecated\) If true, update the cached results after execution |
|
||||
| `updatePublishedResults` | boolean | No | If true, update the published app results after execution |
|
||||
| `useCachedSqlResults` | boolean | No | If true, use cached SQL results instead of re-running queries |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `projectId` | string | Project UUID |
|
||||
| `runId` | string | Run UUID |
|
||||
| `runUrl` | string | URL to view the run |
|
||||
| `runStatusUrl` | string | URL to check run status |
|
||||
| `traceId` | string | Trace ID for debugging |
|
||||
| `projectVersion` | number | Project version number |
|
||||
|
||||
### `hex_update_project`
|
||||
|
||||
Update a Hex project status label (e.g., endorsement or custom workspace statuses).
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
|
||||
| `projectId` | string | Yes | The UUID of the Hex project to update |
|
||||
| `status` | string | Yes | New project status name \(custom workspace status label\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Project UUID |
|
||||
| `title` | string | Project title |
|
||||
| `description` | string | Project description |
|
||||
| `status` | object | Updated project status |
|
||||
| ↳ `name` | string | Status name \(e.g., PUBLISHED, DRAFT\) |
|
||||
| `type` | string | Project type \(PROJECT or COMPONENT\) |
|
||||
| `creator` | object | Project creator |
|
||||
| ↳ `email` | string | Creator email |
|
||||
| `owner` | object | Project owner |
|
||||
| ↳ `email` | string | Owner email |
|
||||
| `categories` | array | Project categories |
|
||||
| ↳ `name` | string | Category name |
|
||||
| ↳ `description` | string | Category description |
|
||||
| `lastEditedAt` | string | Last edited timestamp |
|
||||
| `lastPublishedAt` | string | Last published timestamp |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `archivedAt` | string | Archived timestamp |
|
||||
| `trashedAt` | string | Trashed timestamp |
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ Create a new service request in Jira Service Management
|
||||
| `summary` | string | Yes | Summary/title for the service request |
|
||||
| `description` | string | No | Description for the service request |
|
||||
| `raiseOnBehalfOf` | string | No | Account ID of customer to raise request on behalf of |
|
||||
| `requestFieldValues` | json | No | Custom field values as key-value pairs \(overrides summary/description if provided\) |
|
||||
| `requestFieldValues` | json | No | Request field values as key-value pairs \(overrides summary/description if provided\) |
|
||||
| `requestParticipants` | string | No | Comma-separated account IDs to add as request participants |
|
||||
| `channel` | string | No | Channel the request originates from \(e.g., portal, email\) |
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"grafana",
|
||||
"grain",
|
||||
"greptile",
|
||||
"hex",
|
||||
"hubspot",
|
||||
"huggingface",
|
||||
"hunter",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Slack
|
||||
description: Send, update, delete messages, add reactions in Slack or trigger workflows from Slack events
|
||||
description: Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -59,7 +59,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai](
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
|
||||
| `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) |
|
||||
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
|
||||
| `threadTs` | string | No | Thread timestamp to reply to \(creates thread reply\) |
|
||||
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
|
||||
| `files` | file[] | No | Files to attach to the message |
|
||||
|
||||
#### Output
|
||||
@@ -146,6 +147,29 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
|
||||
| `fileCount` | number | Number of files uploaded \(when files are attached\) |
|
||||
| `files` | file[] | Files attached to the message |
|
||||
|
||||
### `slack_ephemeral_message`
|
||||
|
||||
Send an ephemeral message visible only to a specific user in a channel. Optionally reply in a thread. The message does not persist across sessions.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
|
||||
| `user` | string | Yes | User ID who will see the ephemeral message \(e.g., U1234567890\). Must be a member of the channel. |
|
||||
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
|
||||
| `threadTs` | string | No | Thread timestamp to reply in. When provided, the ephemeral message appears as a thread reply. |
|
||||
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `messageTs` | string | Timestamp of the ephemeral message \(cannot be used with chat.update\) |
|
||||
| `channel` | string | Channel ID where the ephemeral message was sent |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
Create and share Slack canvases in channels. Canvases are collaborative documents within Slack.
|
||||
@@ -682,6 +706,7 @@ Update a message previously sent by the bot in Slack
|
||||
| `channel` | string | Yes | Channel ID where the message was posted \(e.g., C1234567890\) |
|
||||
| `timestamp` | string | Yes | Timestamp of the message to update \(e.g., 1405894322.002768\) |
|
||||
| `text` | string | Yes | New message text \(supports Slack mrkdwn formatting\) |
|
||||
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
274
apps/sim/app/(auth)/oauth/consent/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowLeftRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
|
||||
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
openid: 'Verify your identity',
|
||||
profile: 'Access your basic profile information',
|
||||
email: 'View your email address',
|
||||
offline_access: 'Maintain access when you are not actively using the app',
|
||||
'mcp:tools': 'Use Sim workflows and tools on your behalf',
|
||||
} as const
|
||||
|
||||
interface ClientInfo {
|
||||
clientId: string
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export default function OAuthConsentPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session } = useSession()
|
||||
const consentCode = searchParams.get('consent_code')
|
||||
const clientId = searchParams.get('client_id')
|
||||
const scope = searchParams.get('scope')
|
||||
|
||||
const [clientInfo, setClientInfo] = useState<ClientInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const scopes = scope?.split(' ').filter(Boolean) ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
setLoading(false)
|
||||
setError('The authorization request is missing a required client identifier.')
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
setClientInfo(data)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [clientId])
|
||||
|
||||
const handleConsent = useCallback(
|
||||
async (accept: boolean) => {
|
||||
if (!consentCode) {
|
||||
setError('The authorization request is missing a required consent code.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/oauth2/consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ accept, consent_code: consentCode }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null)
|
||||
setError(
|
||||
(body as Record<string, string> | null)?.message ??
|
||||
'The consent request could not be processed. Please try again.'
|
||||
)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { redirectURI?: string }
|
||||
if (data.redirectURI) {
|
||||
window.location.href = data.redirectURI
|
||||
} else {
|
||||
setError('The server did not return a redirect. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[consentCode]
|
||||
)
|
||||
|
||||
const handleSwitchAccount = useCallback(async () => {
|
||||
if (!consentCode) return
|
||||
|
||||
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError('Unable to switch accounts. Please re-initiate the connection.')
|
||||
return
|
||||
}
|
||||
|
||||
const params = (await res.json()) as Record<string, string | null>
|
||||
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) authorizeUrl.searchParams.set(key, value)
|
||||
}
|
||||
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.href = authorizeUrl.toString()
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [consentCode])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Loading application details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Authorization Error
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const clientName = clientInfo?.name ?? clientId
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='mb-6 flex items-center gap-4'>
|
||||
{clientInfo?.icon ? (
|
||||
<img
|
||||
src={clientInfo.icon}
|
||||
alt={clientName ?? 'Application'}
|
||||
width={48}
|
||||
height={48}
|
||||
className='rounded-[10px]'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
|
||||
{(clientName ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
|
||||
<Image
|
||||
src='/new/logo/colorized-bg.svg'
|
||||
alt='Sim'
|
||||
width={48}
|
||||
height={48}
|
||||
className='rounded-[10px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
|
||||
your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{session?.user && (
|
||||
<div
|
||||
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
|
||||
>
|
||||
{session.user.image ? (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
alt={session.user.name ?? 'User'}
|
||||
width={32}
|
||||
height={32}
|
||||
className='rounded-full'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
|
||||
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0'>
|
||||
{session.user.name && (
|
||||
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
|
||||
)}
|
||||
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSwitchAccount}
|
||||
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scopes.length > 0 && (
|
||||
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
|
||||
<ul className='space-y-2'>
|
||||
{scopes.map((s) => (
|
||||
<li
|
||||
key={s}
|
||||
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
|
||||
>
|
||||
<span className='mt-0.5 text-green-500'>✓</span>
|
||||
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='md'
|
||||
className='px-6 py-2'
|
||||
disabled={submitting}
|
||||
onClick={() => handleConsent(false)}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<BrandedButton
|
||||
fullWidth
|
||||
showArrow={false}
|
||||
loading={submitting}
|
||||
loadingText='Authorizing'
|
||||
onClick={() => handleConsent(true)}
|
||||
>
|
||||
Allow
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
332
apps/sim/app/(home)/components/collaboration/collaboration.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
rows: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CURSOR_KEYFRAMES = `
|
||||
@keyframes cursorVikhyath {
|
||||
0% { transform: translate(0, 0); }
|
||||
12% { transform: translate(120px, 10px); }
|
||||
24% { transform: translate(80px, 80px); }
|
||||
36% { transform: translate(-10px, 60px); }
|
||||
48% { transform: translate(-15px, -20px); }
|
||||
60% { transform: translate(100px, -40px); }
|
||||
72% { transform: translate(180px, 30px); }
|
||||
84% { transform: translate(50px, 50px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
@keyframes cursorAlexa {
|
||||
0% { transform: translate(0, 0); }
|
||||
14% { transform: translate(45px, -35px); }
|
||||
28% { transform: translate(-75px, 20px); }
|
||||
42% { transform: translate(25px, -50px); }
|
||||
57% { transform: translate(-65px, 15px); }
|
||||
71% { transform: translate(35px, -30px); }
|
||||
85% { transform: translate(-30px, -10px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes cursorVikhyath { 0%, 100% { transform: none; } }
|
||||
@keyframes cursorAlexa { 0%, 100% { transform: none; } }
|
||||
}
|
||||
`
|
||||
|
||||
const CURSOR_ARROW_PATH =
|
||||
'M17.135 2.198L12.978 14.821C12.478 16.339 10.275 16.16 10.028 14.581L9.106 8.703C9.01 8.092 8.554 7.599 7.952 7.457L1.591 5.953C0 5.577 0.039 3.299 1.642 2.978L15.39 0.229C16.534 0 17.499 1.09 17.135 2.198Z'
|
||||
|
||||
const CURSOR_ARROW_MIRRORED_PATH =
|
||||
'M0.365 2.198L4.522 14.821C5.022 16.339 7.225 16.16 7.472 14.58L8.394 8.702C8.49 8.091 8.946 7.599 9.548 7.456L15.909 5.953C17.5 5.577 17.461 3.299 15.857 2.978L2.11 0.228C0.966 0 0.001 1.09 0.365 2.198Z'
|
||||
|
||||
function CursorArrow({ fill }: { fill: string }) {
|
||||
return (
|
||||
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
|
||||
<path d={fill === '#2ABBF8' ? CURSOR_ARROW_PATH : CURSOR_ARROW_MIRRORED_PATH} fill={fill} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function VikhyathCursor() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
top: '27.47%',
|
||||
left: '25%',
|
||||
animation: 'cursorVikhyath 16s ease-in-out infinite',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div className='relative h-[37.14px] w-[79.18px]'>
|
||||
<div className='absolute top-0 left-[56.02px]'>
|
||||
<CursorArrow fill='#2ABBF8' />
|
||||
</div>
|
||||
<div className='-left-[4px] absolute top-[18px] flex items-center rounded bg-[#2ABBF8] px-[5px] py-[3px] font-[440] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
Vikhyath
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AlexaCursor() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
top: '66.80%',
|
||||
left: '49%',
|
||||
animation: 'cursorAlexa 13s ease-in-out infinite',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div className='relative h-[35.09px] w-[62.16px]'>
|
||||
<div className='absolute top-0 left-0'>
|
||||
<CursorArrow fill='#FFCC02' />
|
||||
</div>
|
||||
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#FFCC02] px-[5px] py-[3px] font-[440] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
Alexa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface YouCursorProps {
|
||||
x: number
|
||||
y: number
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
function YouCursor({ x, y, visible }: YouCursorProps) {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none fixed z-50'
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
transform: 'translate(-2px, -2px)',
|
||||
}}
|
||||
>
|
||||
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
|
||||
<path d={CURSOR_ARROW_MIRRORED_PATH} fill='#33C482' />
|
||||
</svg>
|
||||
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#33C482] px-[5px] py-[3px] font-[440] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
You
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaboration section — team workflows and real-time collaboration.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="collaboration" aria-labelledby="collaboration-heading">`.
|
||||
* - `<h2 id="collaboration-heading">` for the section title.
|
||||
* - Product visuals use `<figure>` with `<figcaption>` and descriptive `alt` text.
|
||||
*
|
||||
* GEO:
|
||||
* - Name specific capabilities (version control, shared workspaces, RBAC, audit logs).
|
||||
* - Lead with a summary so AI can answer "Does Sim support team collaboration?".
|
||||
* - Reference "Sim" by name per capability ("Sim's real-time collaboration").
|
||||
*/
|
||||
|
||||
const CURSOR_LERP_FACTOR = 0.3
|
||||
|
||||
export default function Collaboration() {
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 })
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const targetPos = useRef({ x: 0, y: 0 })
|
||||
const animationRef = useRef<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
setCursorPos((prev) => ({
|
||||
x: prev.x + (targetPos.current.x - prev.x) * CURSOR_LERP_FACTOR,
|
||||
y: prev.y + (targetPos.current.y - prev.y) * CURSOR_LERP_FACTOR,
|
||||
}))
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
if (isHovering) {
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [isHovering])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
targetPos.current = { x: e.clientX, y: e.clientY }
|
||||
}, [])
|
||||
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent) => {
|
||||
targetPos.current = { x: e.clientX, y: e.clientY }
|
||||
setCursorPos({ x: e.clientX, y: e.clientY })
|
||||
setIsHovering(true)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovering(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id='collaboration'
|
||||
aria-labelledby='collaboration-heading'
|
||||
className='bg-[#1C1C1C]'
|
||||
style={{ cursor: isHovering ? 'none' : 'auto' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<YouCursor x={cursorPos.x} y={cursorPos.y} visible={isHovering} />
|
||||
<style dangerouslySetInnerHTML={{ __html: CURSOR_KEYFRAMES }} />
|
||||
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='relative overflow-hidden'>
|
||||
<Link
|
||||
href='/studio/multiplayer'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='absolute bottom-10 left-4 z-20 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:left-8 md:left-[80px]'
|
||||
>
|
||||
<div className='relative h-7 w-11 shrink-0'>
|
||||
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
|
||||
Blog
|
||||
</span>
|
||||
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
How we built realtime collaboration
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className='grid grid-cols-[auto_1fr]'>
|
||||
<div className='flex flex-col items-start gap-3 px-4 pt-[100px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#33C482]/10 font-season text-[#33C482] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Teams
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='collaboration-heading'
|
||||
className='font-[430] font-season text-[32px] text-white leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Realtime
|
||||
<br />
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
Grab your team. Build agents together <br /> in real-time inside your workspace.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
Build together
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<figure className='pointer-events-none relative h-[600px] w-full'>
|
||||
<div className='-left-[18%] absolute inset-y-0 min-w-full'>
|
||||
<Image
|
||||
src='/landing/collaboration-visual.svg'
|
||||
alt='Collaboration visual showing team workflows with real-time editing, shared cursors, and version control interface'
|
||||
width={876}
|
||||
height={480}
|
||||
className='h-full w-auto min-w-[100vw] object-left'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden lg:block'>
|
||||
<VikhyathCursor />
|
||||
<AlexaCursor />
|
||||
</div>
|
||||
<figcaption className='sr-only'>
|
||||
Sim collaboration interface with real-time cursors, shared workspace, and team
|
||||
presence indicators
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
17
apps/sim/app/(home)/components/enterprise/enterprise.tsx
Normal file
@@ -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
|
||||
}
|
||||
42
apps/sim/app/(home)/components/features/features.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Image from 'next/image'
|
||||
import { Badge } from '@/components/emcn'
|
||||
|
||||
export default function Features() {
|
||||
return (
|
||||
<section
|
||||
id='features'
|
||||
aria-labelledby='features-heading'
|
||||
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
|
||||
>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
|
||||
<Image
|
||||
src='/landing/features-transition.svg'
|
||||
alt=''
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 px-[80px] pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#FA4EDF]/10 font-season text-[#FA4EDF] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Integrations
|
||||
</Badge>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Everything you need
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/components/footer/footer.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Landing page footer — navigation, legal links, and entity reinforcement.
|
||||
*
|
||||
* SEO:
|
||||
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
|
||||
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
|
||||
* - External links include `rel="noopener noreferrer"`.
|
||||
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
|
||||
*
|
||||
* GEO:
|
||||
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
|
||||
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
|
||||
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
|
||||
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
|
||||
*/
|
||||
export default function Footer() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
|
||||
/** Stagger between each block appearing (seconds). */
|
||||
const ENTER_STAGGER = 0.06
|
||||
|
||||
/** Duration of each block's fade-in (seconds). */
|
||||
const ENTER_DURATION = 0.3
|
||||
|
||||
/** Stagger between each block disappearing (seconds). */
|
||||
const EXIT_STAGGER = 0.12
|
||||
|
||||
/** Duration of each block's fade-out (seconds). */
|
||||
const EXIT_DURATION = 0.5
|
||||
|
||||
/** Shared corner radius for all decorative rects. */
|
||||
const RX = '2.59574'
|
||||
|
||||
/** Hold time after the initial enter animation before cycling starts (ms). */
|
||||
const INITIAL_HOLD_MS = 2500
|
||||
|
||||
/** Pause between an exit completing and the next enter starting (ms). */
|
||||
const TRANSITION_PAUSE_MS = 400
|
||||
|
||||
/** Hold time between successive transitions (ms). */
|
||||
const HOLD_BETWEEN_MS = 2500
|
||||
|
||||
/** Animation state for a block group. */
|
||||
export type BlockAnimState = 'entering' | 'visible' | 'exiting' | 'hidden'
|
||||
|
||||
/** Positions around the hero where block groups can appear. */
|
||||
export type BlockPosition = 'topRight' | 'left' | 'rightEdge' | 'rightSide' | 'topLeft'
|
||||
|
||||
/** Attributes for a single animated SVG rect. */
|
||||
interface BlockRect {
|
||||
opacity: number
|
||||
width: string
|
||||
height: string
|
||||
fill: string
|
||||
x?: string
|
||||
y?: string
|
||||
transform?: string
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: ENTER_STAGGER } },
|
||||
exit: { transition: { staggerChildren: EXIT_STAGGER } },
|
||||
}
|
||||
|
||||
const blockVariants: Variants = {
|
||||
hidden: { opacity: 0, transition: { duration: 0 } },
|
||||
visible: (targetOpacity: number) => ({
|
||||
opacity: targetOpacity,
|
||||
transition: { duration: ENTER_DURATION },
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { duration: EXIT_DURATION },
|
||||
},
|
||||
}
|
||||
|
||||
/** Maps a BlockAnimState to the framer-motion animate value. */
|
||||
function toAnimateValue(state: BlockAnimState): string {
|
||||
if (state === 'entering' || state === 'visible') return 'visible'
|
||||
if (state === 'exiting') return 'exit'
|
||||
return 'hidden'
|
||||
}
|
||||
|
||||
/** Shared SVG wrapper that staggers child rects in and out. */
|
||||
function AnimatedBlocksSvg({
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
rects,
|
||||
animState = 'entering',
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
animState?: BlockAnimState
|
||||
}) {
|
||||
return (
|
||||
<motion.svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={viewBox}
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
initial='hidden'
|
||||
animate={toAnimateValue(animState)}
|
||||
variants={containerVariants}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
variants={blockVariants}
|
||||
custom={r.opacity}
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
width={r.width}
|
||||
height={r.height}
|
||||
rx={RX}
|
||||
fill={r.fill}
|
||||
transform={r.transform}
|
||||
/>
|
||||
))}
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect data for the top-right position.
|
||||
* Two-row horizontal strip, ordered left-to-right.
|
||||
*/
|
||||
const TOP_RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the top-left position.
|
||||
* Same two-row structure as top-right with rotated colour palette:
|
||||
* blue→green, green→yellow, yellow→pink, pink→blue.
|
||||
*/
|
||||
const TOP_LEFT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the left position.
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const LEFT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-side position (right edge of screenshot).
|
||||
* Same two-column structure as left with rotated colours:
|
||||
* pink→blue, green→pink, yellow→green.
|
||||
*/
|
||||
const RIGHT_SIDE_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-edge position (far right of screen).
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 16.891 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.482',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 16.888)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 33.776)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 34.272)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.012 68.510)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '102.384',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.787 102.384)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
]
|
||||
|
||||
/** Number of rects per position, used to compute animation durations. */
|
||||
const RECT_COUNTS: Record<BlockPosition, number> = {
|
||||
topRight: TOP_RIGHT_RECTS.length,
|
||||
topLeft: TOP_LEFT_RECTS.length,
|
||||
left: LEFT_RECTS.length,
|
||||
rightSide: RIGHT_SIDE_RECTS.length,
|
||||
rightEdge: RIGHT_RECTS.length,
|
||||
}
|
||||
|
||||
/** Total enter animation time for a position (seconds). */
|
||||
function enterTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * ENTER_STAGGER + ENTER_DURATION
|
||||
}
|
||||
|
||||
/** Total exit animation time for a position (seconds). */
|
||||
function exitTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * EXIT_STAGGER + EXIT_DURATION
|
||||
}
|
||||
|
||||
/** A single step in the repeating animation cycle. */
|
||||
type CycleStep =
|
||||
| { action: 'exit'; position: BlockPosition }
|
||||
| { action: 'enter'; position: BlockPosition }
|
||||
| { action: 'hold'; ms: number }
|
||||
|
||||
/**
|
||||
* The repeating cycle sequence. After all steps, the layout returns to its
|
||||
* initial state (topRight + left + rightEdge) so the loop is seamless.
|
||||
*
|
||||
* Order: exit top → exit right-edge → enter right-side-of-preview →
|
||||
* exit left → enter top-left → exit right-side → enter left →
|
||||
* exit top-left → enter top-right → enter right-edge → back to initial.
|
||||
*/
|
||||
const CYCLE_STEPS: readonly CycleStep[] = [
|
||||
{ action: 'exit', position: 'topRight' },
|
||||
{ action: 'exit', position: 'rightEdge' },
|
||||
{ action: 'enter', position: 'rightSide' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'left' },
|
||||
{ action: 'enter', position: 'topLeft' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'rightSide' },
|
||||
{ action: 'enter', position: 'left' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'topLeft' },
|
||||
{ action: 'enter', position: 'topRight' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'enter', position: 'rightEdge' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
]
|
||||
|
||||
/**
|
||||
* Drives the block-cycling animation loop. Returns the current animation
|
||||
* state for every position so each component can be driven declaratively.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. All three initial groups (topRight, left, rightEdge) enter together.
|
||||
* 2. After a hold period the cycle begins, processing each step in order.
|
||||
* 3. Repeats indefinitely, returning to the initial layout every cycle.
|
||||
*/
|
||||
export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
|
||||
const [states, setStates] = useState<Record<BlockPosition, BlockAnimState>>({
|
||||
topRight: 'entering',
|
||||
left: 'entering',
|
||||
rightEdge: 'entering',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const cancelled = { current: false }
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const run = async () => {
|
||||
const longestEnter = Math.max(
|
||||
enterTime('topRight'),
|
||||
enterTime('left'),
|
||||
enterTime('rightEdge')
|
||||
)
|
||||
await delay(longestEnter * 1000)
|
||||
if (cancelled.current) return
|
||||
|
||||
setStates({
|
||||
topRight: 'visible',
|
||||
left: 'visible',
|
||||
rightEdge: 'visible',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
await delay(INITIAL_HOLD_MS)
|
||||
if (cancelled.current) return
|
||||
|
||||
while (!cancelled.current) {
|
||||
for (const step of CYCLE_STEPS) {
|
||||
if (cancelled.current) return
|
||||
|
||||
if (step.action === 'exit') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'exiting' }))
|
||||
await delay(exitTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'hidden' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else if (step.action === 'enter') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'entering' }))
|
||||
await delay(enterTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'visible' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else {
|
||||
await delay(step.ms)
|
||||
}
|
||||
|
||||
if (cancelled.current) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
return () => {
|
||||
cancelled.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
interface AnimatedBlockProps {
|
||||
animState?: BlockAnimState
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-right of the hero. */
|
||||
export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_RIGHT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-left of the hero. */
|
||||
export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_LEFT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the left edge of the screenshot. */
|
||||
export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={LEFT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the right edge of the screenshot. */
|
||||
export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={RIGHT_SIDE_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip at the far-right edge of the screen. */
|
||||
export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={205}
|
||||
viewBox='0 0 34 204.769'
|
||||
rects={RIGHT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
132
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
BlocksLeftAnimated,
|
||||
BlocksRightAnimated,
|
||||
BlocksRightSideAnimated,
|
||||
BlocksTopLeftAnimated,
|
||||
BlocksTopRightAnimated,
|
||||
useBlockCycle,
|
||||
} from '@/app/(home)/components/hero/components/animated-blocks'
|
||||
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(home)/components/landing-preview/landing-preview').then(
|
||||
(mod) => mod.LandingPreview
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[#1b1b1b]' />,
|
||||
}
|
||||
)
|
||||
|
||||
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||
|
||||
export default function Hero() {
|
||||
const blockStates = useBlockCycle()
|
||||
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
1,000+ integrations and LLMs — including OpenAI, Claude, Gemini, Mistral, and xAI — to
|
||||
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
|
||||
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
|
||||
HIPAA compliant.
|
||||
</p>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw]'
|
||||
>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Build Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
Build and deploy agentic workflows
|
||||
</p>
|
||||
|
||||
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopRightAnimated animState={blockStates.topRight} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksLeftAnimated animState={blockStates.left} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
|
||||
>
|
||||
<BlocksRightSideAnimated animState={blockStates.rightSide} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 overflow-hidden rounded border border-[#2A2A2A]'>
|
||||
<LandingPreview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksRightAnimated animState={blockStates.rightEdge} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/(home)/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Collaboration from '@/app/(home)/components/collaboration/collaboration'
|
||||
import Enterprise from '@/app/(home)/components/enterprise/enterprise'
|
||||
import Features from '@/app/(home)/components/features/features'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Hero from '@/app/(home)/components/hero/hero'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import Pricing from '@/app/(home)/components/pricing/pricing'
|
||||
import StructuredData from '@/app/(home)/components/structured-data'
|
||||
import Templates from '@/app/(home)/components/templates/templates'
|
||||
import Testimonials from '@/app/(home)/components/testimonials/testimonials'
|
||||
|
||||
export {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
Features,
|
||||
Footer,
|
||||
Hero,
|
||||
Navbar,
|
||||
Pricing,
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
/**
|
||||
* Lightweight static panel replicating the real workspace panel styling.
|
||||
* The copilot tab is active with a functional user input.
|
||||
* When submitted, stores the prompt and redirects to /signup (same as landing hero).
|
||||
*
|
||||
* Structure mirrors the real Panel component:
|
||||
* aside > div.border-l.pt-[14px] > Header(px-8) > Tabs(px-8,pt-14) > Content(pt-12)
|
||||
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
|
||||
*/
|
||||
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
const router = useRouter()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
LandingPromptStorage.store(inputValue)
|
||||
router.push('/signup')
|
||||
}, [isEmpty, inputValue, router])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
|
||||
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-[14px]'>
|
||||
{/* Header — More + Chat | Deploy + Run */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-[8px]'>
|
||||
<div className='pointer-events-none flex gap-[6px]'>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex gap-[6px]'
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||
</div>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</div>
|
||||
</Link>
|
||||
{cursorPos &&
|
||||
createPortal(
|
||||
<div
|
||||
className='pointer-events-none fixed z-[9999]'
|
||||
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
|
||||
>
|
||||
{/* Decorative color bars — mirrors hero top-right block sequence */}
|
||||
<div className='flex h-[4px]'>
|
||||
<div className='h-full w-[8px] bg-[#2ABBF8]' />
|
||||
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#00F701]' />
|
||||
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FFCC02]' />
|
||||
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FA4EDF]' />
|
||||
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[5px] bg-white px-[6px] py-[4px] font-medium text-[#1C1C1C] text-[11px]'>
|
||||
Get started
|
||||
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className='flex flex-shrink-0 items-center px-[8px] pt-[14px]'>
|
||||
<div className='pointer-events-none flex gap-[4px]'>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-[8px] py-[5px]'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[12.5px]'>Copilot</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Toolbar</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Editor</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content — copilot */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-[12px]'>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Copilot header bar — matches mx-[-1px] in real copilot */}
|
||||
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-[12px] py-[6px]'>
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[14px]'>New Chat</span>
|
||||
</div>
|
||||
|
||||
{/* User input — matches real UserInput at p-[8px] inside copilot welcome state */}
|
||||
<div className='px-[8px] pt-[12px] pb-[8px]'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-[6px] py-[6px]'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Build an AI agent...'
|
||||
rows={2}
|
||||
className='mb-[6px] min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-[2px] py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Database, Layout, Search, Settings } from 'lucide-react'
|
||||
import { ChevronDown, Library } from '@/components/emcn'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* Props for the LandingPreviewSidebar component
|
||||
*/
|
||||
interface LandingPreviewSidebarProps {
|
||||
workflows: PreviewWorkflow[]
|
||||
activeWorkflowId: string
|
||||
onSelectWorkflow: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Static footer navigation items matching the real sidebar
|
||||
*/
|
||||
const FOOTER_NAV_ITEMS = [
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
{ id: 'templates', label: 'Templates', icon: Layout },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Lightweight static sidebar replicating the real workspace sidebar styling.
|
||||
* Only workflow items are interactive — everything else is pointer-events-none.
|
||||
*
|
||||
* Colors sourced from the dark theme CSS variables:
|
||||
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
|
||||
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
|
||||
*/
|
||||
export function LandingPreviewSidebar({
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
onSelectWorkflow,
|
||||
}: LandingPreviewSidebarProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsDropdownOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isDropdownOpen])
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
|
||||
{/* Header */}
|
||||
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggle}
|
||||
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
|
||||
>
|
||||
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div className='pointer-events-none flex flex-shrink-0 items-center'>
|
||||
<Search className='h-[14px] w-[14px] text-[#787878]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace switcher dropdown */}
|
||||
{isDropdownOpen && (
|
||||
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
|
||||
<div
|
||||
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
|
||||
role='menuitem'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow items */}
|
||||
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
|
||||
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div
|
||||
className={`min-w-0 truncate text-left font-medium ${
|
||||
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
|
||||
}`}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer navigation — static */}
|
||||
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
|
||||
{FOOTER_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
|
||||
>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
|
||||
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import ReactFlow, {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type EdgeTypes,
|
||||
getSmoothStepPath,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { PreviewBlockNode } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type PreviewWorkflow,
|
||||
toReactFlowElements,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
interface FitViewOptions {
|
||||
padding?: number
|
||||
maxZoom?: number
|
||||
}
|
||||
|
||||
interface LandingPreviewWorkflowProps {
|
||||
workflow: PreviewWorkflow
|
||||
animate?: boolean
|
||||
fitViewOptions?: FitViewOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom edge that draws left-to-right on initial load via stroke animation.
|
||||
* Falls back to a static path when `data.animate` is false.
|
||||
*/
|
||||
function PreviewEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
if (data?.animate) {
|
||||
return (
|
||||
<motion.path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{
|
||||
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
|
||||
opacity: { duration: 0.15, delay: data.delay ?? 0 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NODE_TYPES: NodeTypes = { previewBlock: PreviewBlockNode }
|
||||
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1 } as const
|
||||
|
||||
/**
|
||||
* Inner flow component. Keyed on workflow ID by the parent so it remounts
|
||||
* cleanly on workflow switch — fitView fires on mount with zero delay.
|
||||
*/
|
||||
function PreviewFlow({ workflow, animate = false, fitViewOptions }: LandingPreviewWorkflowProps) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => toReactFlowElements(workflow, animate),
|
||||
[workflow, animate]
|
||||
)
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initialEdges)
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[]
|
||||
)
|
||||
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
|
||||
[]
|
||||
)
|
||||
|
||||
const resolvedFitViewOptions = fitViewOptions ?? DEFAULT_FIT_VIEW_OPTIONS
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
panOnDrag
|
||||
preventScrolling={false}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
fitView
|
||||
fitViewOptions={resolvedFitViewOptions}
|
||||
className='h-full w-full bg-[#1b1b1b]'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight ReactFlow canvas displaying an interactive workflow preview.
|
||||
* The key on workflow.id forces a clean remount on switch — instant fitView,
|
||||
* no timers, no flicker.
|
||||
*/
|
||||
export function LandingPreviewWorkflow({
|
||||
workflow,
|
||||
animate = false,
|
||||
fitViewOptions,
|
||||
}: LandingPreviewWorkflowProps) {
|
||||
return (
|
||||
<div className='h-full w-full'>
|
||||
<ReactFlowProvider key={workflow.id}>
|
||||
<PreviewFlow workflow={workflow} animate={animate} fitViewOptions={fitViewOptions} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
FirecrawlIcon,
|
||||
GeminiIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleSheetsIcon,
|
||||
JiraIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
MistralIcon,
|
||||
NotionIcon,
|
||||
OpenAIIcon,
|
||||
RedditIcon,
|
||||
ReductoIcon,
|
||||
ScheduleIcon,
|
||||
SlackIcon,
|
||||
StartIcon,
|
||||
SupabaseIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
WebhookIcon,
|
||||
xAIIcon,
|
||||
xIcon,
|
||||
YouTubeIcon,
|
||||
} from '@/components/icons'
|
||||
import {
|
||||
BLOCK_STAGGER,
|
||||
EASE_OUT,
|
||||
type PreviewTool,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/** Map block type strings to their icon components. */
|
||||
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
starter: StartIcon,
|
||||
start_trigger: StartIcon,
|
||||
agent: AgentIcon,
|
||||
slack: SlackIcon,
|
||||
jira: JiraIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
schedule: ScheduleIcon,
|
||||
telegram: TelegramIcon,
|
||||
knowledge_base: Database,
|
||||
webhook: WebhookIcon,
|
||||
github: GithubIcon,
|
||||
supabase: SupabaseIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
gmail: GmailIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
linear: LinearIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
reddit: RedditIcon,
|
||||
notion: NotionIcon,
|
||||
reducto: ReductoIcon,
|
||||
textract: TextractIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
}
|
||||
|
||||
/** Model prefix → provider icon for the "Model" row in agent blocks. */
|
||||
const MODEL_PROVIDER_ICONS: Array<{
|
||||
prefix: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
size?: string
|
||||
}> = [
|
||||
{ prefix: 'gpt-', icon: OpenAIIcon },
|
||||
{ prefix: 'o3', icon: OpenAIIcon },
|
||||
{ prefix: 'o4', icon: OpenAIIcon },
|
||||
{ prefix: 'claude-', icon: AnthropicIcon },
|
||||
{ prefix: 'gemini-', icon: GeminiIcon },
|
||||
{ prefix: 'grok-', icon: xAIIcon, size: 'h-[17px] w-[17px]' },
|
||||
{ prefix: 'mistral-', icon: MistralIcon },
|
||||
]
|
||||
|
||||
function getModelIconEntry(modelValue: string) {
|
||||
const lower = modelValue.toLowerCase()
|
||||
return MODEL_PROVIDER_ICONS.find((m) => lower.startsWith(m.prefix)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Data shape for preview block nodes
|
||||
*/
|
||||
interface PreviewBlockData {
|
||||
name: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
tools?: PreviewTool[]
|
||||
markdown?: string
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
index?: number
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle styling matching the real WorkflowBlock handles.
|
||||
* --workflow-edge in dark mode: #454545
|
||||
*/
|
||||
const HANDLE_BASE = '!z-[10] !border-none !bg-[#454545]'
|
||||
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
|
||||
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
|
||||
|
||||
/**
|
||||
* Static preview block node matching the real WorkflowBlock styling.
|
||||
* Renders a block header with icon + name, sub-block rows, and tool chips.
|
||||
*
|
||||
* Colors sourced from dark theme CSS variables:
|
||||
* --surface-2: #232323, --border-1: #3d3d3d
|
||||
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3
|
||||
*/
|
||||
export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
data,
|
||||
}: NodeProps<PreviewBlockData>) {
|
||||
const {
|
||||
name,
|
||||
blockType,
|
||||
bgColor,
|
||||
rows,
|
||||
tools,
|
||||
markdown,
|
||||
hideTargetHandle,
|
||||
hideSourceHandle,
|
||||
index = 0,
|
||||
animate = false,
|
||||
} = data
|
||||
const Icon = BLOCK_ICONS[blockType]
|
||||
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||
|
||||
if (blockType === 'note' && markdown) {
|
||||
return (
|
||||
<motion.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='w-[280px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
<div className='border-[#3d3d3d] border-b p-[8px]'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[16px]'>Note</span>
|
||||
</div>
|
||||
<div className='p-[10px]'>
|
||||
<NoteMarkdown content={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasContent = rows.length > 0 || (tools && tools.length > 0)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
{/* Target handle (left side) */}
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={HANDLE_LEFT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-[8px] ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && <Icon className='h-[16px] w-[16px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[16px]'>{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-block rows + tools */}
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{rows.map((row) => {
|
||||
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
|
||||
const ModelIcon = modelEntry?.icon
|
||||
return (
|
||||
<div key={row.title} className='flex items-center gap-[8px]'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-[5px] font-normal text-[#e6e6e6] text-[14px]'>
|
||||
{ModelIcon && (
|
||||
<ModelIcon
|
||||
className={`inline-block flex-shrink-0 text-[#e6e6e6] ${modelEntry.size ?? 'h-[14px] w-[14px]'}`}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tool chips — inline with label */}
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||
>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{ToolIcon && <ToolIcon className='h-[10px] w-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='font-normal text-[#e6e6e6] text-[12px]'>{tool.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source handle (right side) */}
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={HANDLE_RIGHT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders lightweight markdown-like content for note blocks.
|
||||
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
|
||||
*/
|
||||
function NoteMarkdown({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
{lines.map((line, i) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return <div key={i} className='h-[4px]' />
|
||||
|
||||
if (trimmed === '---') {
|
||||
return <hr key={i} className='my-[4px] border-[#3d3d3d] border-t' />
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
return (
|
||||
<p key={i} className='font-semibold text-[#e6e6e6] text-[16px] leading-[1.3]'>
|
||||
{trimmed.slice(4)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className='font-medium text-[#e6e6e6] text-[13px] leading-[1.5]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: trimmed
|
||||
.replace(/\*\*_(.+?)_\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/_"(.+?)"_/g, '<em>“$1”</em>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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: '#FF6B2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#it-support' },
|
||||
{ title: 'Event', value: 'New Message' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Triage incoming IT...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#10B981' },
|
||||
{ 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: '#33C482',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||
],
|
||||
position: { x: 420, y: 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 Linear todos and send in Slack"_',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
CONTENT_PIPELINE_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
/** Stagger delay between each block appearing (seconds). */
|
||||
export const BLOCK_STAGGER = 0.12
|
||||
|
||||
/** Shared cubic-bezier easing — fast deceleration, gentle settle. */
|
||||
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||
|
||||
/** Shared edge style applied to all preview workflow connections */
|
||||
const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
|
||||
|
||||
/**
|
||||
* Converts a PreviewWorkflow to React Flow nodes and edges.
|
||||
*
|
||||
* @param workflow - The workflow definition
|
||||
* @param animate - When true, node/edge data includes animation metadata
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false
|
||||
): {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
} {
|
||||
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => ({
|
||||
id: block.id,
|
||||
type: 'previewBlock',
|
||||
position: block.position,
|
||||
data: {
|
||||
name: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
tools: block.tools,
|
||||
markdown: block.markdown,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
hideSourceHandle: block.hideSourceHandle,
|
||||
index,
|
||||
animate,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}))
|
||||
|
||||
const edges: Edge[] = workflow.edges.map((e) => {
|
||||
const sourceIndex = blockIndexMap.get(e.source) ?? 0
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: EDGE_STYLE,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
import {
|
||||
EASE_OUT,
|
||||
PREVIEW_WORKFLOWS,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.15 },
|
||||
},
|
||||
}
|
||||
|
||||
const sidebarVariants: Variants = {
|
||||
hidden: { opacity: 0, x: -12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
x: { duration: 0.25, ease: EASE_OUT },
|
||||
opacity: { duration: 0.25, ease: EASE_OUT },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const panelVariants: Variants = {
|
||||
hidden: { opacity: 0, x: 12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
x: { duration: 0.25, ease: EASE_OUT },
|
||||
opacity: { duration: 0.25, ease: EASE_OUT },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive workspace preview for the hero section.
|
||||
*
|
||||
* Renders a lightweight replica of the Sim workspace with:
|
||||
* - A sidebar with two selectable workflows
|
||||
* - A ReactFlow canvas showing the active workflow's blocks and edges
|
||||
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
|
||||
*
|
||||
* Everything except the workflow items and the copilot input is non-interactive.
|
||||
* On mount the sidebar slides from left and the panel from right. The canvas
|
||||
* background stays fully opaque; individual block nodes animate in with a
|
||||
* staggered fade. Edges draw left-to-right. Animations only fire on initial
|
||||
* load — workflow switches render instantly.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const isInitialMount = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isInitialMount.current = false
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
onSelectWorkflow={setActiveWorkflowId}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
</div>
|
||||
<motion.div className='hidden lg:flex' variants={panelVariants}>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
|
||||
const logger = createLogger('github-stars')
|
||||
|
||||
const INITIAL_STARS = '26.4k'
|
||||
|
||||
/**
|
||||
* Client component that displays GitHub stars count.
|
||||
*
|
||||
* Isolated as a client component to allow the parent Navbar to remain
|
||||
* a Server Component for optimal SEO/GEO crawlability.
|
||||
*/
|
||||
export function GitHubStars() {
|
||||
const [stars, setStars] = useState(INITIAL_STARS)
|
||||
|
||||
useEffect(() => {
|
||||
getFormattedGitHubStars()
|
||||
.then(setStars)
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to fetch GitHub stars', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-[8px] px-[12px]'
|
||||
aria-label={`GitHub repository — ${stars} stars`}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
<span aria-live='polite'>{stars}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
97
apps/sim/app/(home)/components/navbar/navbar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
|
||||
|
||||
interface NavLink {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
icon?: 'chevron'
|
||||
}
|
||||
|
||||
const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Docs', href: '/docs', icon: 'chevron' },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' },
|
||||
]
|
||||
|
||||
/** Logo and nav edge: horizontal padding (px) for left/right symmetry. */
|
||||
const LOGO_CELL = 'flex items-center px-[20px]'
|
||||
|
||||
/** Links: even spacing between items. */
|
||||
const LINK_CELL = 'flex items-center px-[14px]'
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<nav
|
||||
aria-label='Primary navigation'
|
||||
className='flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link href='/' className={LOGO_CELL} aria-label='Sim home' itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
Sim
|
||||
</span>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Links */}
|
||||
<ul className='mt-[0.75px] flex'>
|
||||
{NAV_LINKS.map(({ label, href, external, icon }) => (
|
||||
<li key={label} className='flex'>
|
||||
{external ? (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
{icon === 'chevron' && (
|
||||
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li className='flex'>
|
||||
<GitHubStars />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className='flex-1' />
|
||||
|
||||
{/* CTAs */}
|
||||
<div className='flex items-center gap-[8px] px-[20px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
17
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal 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
|
||||
}
|
||||
224
apps/sim/app/(home)/components/structured-data.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* JSON-LD structured data for the landing page.
|
||||
*
|
||||
* Renders a `<script type="application/ld+json">` with Schema.org markup.
|
||||
* Single source of truth for machine-readable page metadata.
|
||||
*
|
||||
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, FAQPage.
|
||||
*
|
||||
* AI crawler behavior (2025-2026):
|
||||
* - Google AI Overviews / Bing Copilot parse JSON-LD from their search indexes.
|
||||
* - GPTBot indexes JSON-LD during crawling (92% of LLM crawlers parse JSON-LD first).
|
||||
* - Perplexity / Claude prioritize visible HTML over JSON-LD during direct fetch.
|
||||
* - All claims here must also appear as visible text on the page.
|
||||
*
|
||||
* Maintenance:
|
||||
* - Offer prices must match the Pricing component exactly.
|
||||
* - `sameAs` links must match the Footer social links.
|
||||
* - Do not add `aggregateRating` without real, verifiable review data.
|
||||
*/
|
||||
export default function StructuredData() {
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
name: 'Sim',
|
||||
alternateName: 'Sim Studio',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
url: 'https://sim.ai',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
'@id': 'https://sim.ai/#logo',
|
||||
url: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||
contentUrl: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||
width: 49.78314,
|
||||
height: 24.276,
|
||||
caption: 'Sim Logo',
|
||||
},
|
||||
image: { '@id': 'https://sim.ai/#logo' },
|
||||
sameAs: [
|
||||
'https://x.com/simdotai',
|
||||
'https://github.com/simstudioai/sim',
|
||||
'https://www.linkedin.com/company/simstudioai/',
|
||||
'https://discord.gg/Hr4UWYEcTT',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer support',
|
||||
availableLanguage: ['en'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://sim.ai/#website',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
|
||||
publisher: { '@id': 'https://sim.ai/#organization' },
|
||||
inLanguage: 'en-US',
|
||||
},
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': 'https://sim.ai/#webpage',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
isPartOf: { '@id': 'https://sim.ai/#website' },
|
||||
about: { '@id': 'https://sim.ai/#software' },
|
||||
datePublished: '2024-01-01T00:00:00+00:00',
|
||||
dateModified: new Date().toISOString(),
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
breadcrumb: { '@id': 'https://sim.ai/#breadcrumb' },
|
||||
inLanguage: 'en-US',
|
||||
potentialAction: [{ '@type': 'ReadAction', target: ['https://sim.ai'] }],
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': 'https://sim.ai/#breadcrumb',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'WebApplication',
|
||||
'@id': 'https://sim.ai/#software',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
browserRequirements: 'Requires a modern browser with JavaScript enabled',
|
||||
offers: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Community Plan',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Pro Plan',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Team Plan',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
],
|
||||
featureList: [
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
'Event triggers',
|
||||
],
|
||||
review: [
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'Hasan Toor' },
|
||||
reviewBody:
|
||||
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
|
||||
url: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'nizzy' },
|
||||
reviewBody:
|
||||
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
|
||||
url: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Organization', name: 'xyflow' },
|
||||
reviewBody: 'A very good looking agent workflow builder and open source!',
|
||||
url: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://sim.ai/#faq',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Which AI models does Sim support?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim supports all major AI models including OpenAI (GPT-5, GPT-4o), Anthropic (Claude), Google (Gemini), xAI (Grok), Mistral, Perplexity, and many more. You can also connect to open-source models via Ollama.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How much does Sim cost?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers a free Community plan with $20 usage limit, a Pro plan at $20/month, a Team plan at $40/month, and custom Enterprise pricing. All plans include CLI/SDK access.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need coding skills to use Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What enterprise features does Sim offer?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
582
apps/sim/app/(home)/components/templates/template-workflows.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* OCR Invoice to DB — Start → Agent (Textract) → Supabase
|
||||
* Pattern: Straight line (all blocks aligned at top)
|
||||
*/
|
||||
const OCR_INVOICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-ocr-invoice',
|
||||
name: 'OCR Invoice to DB',
|
||||
color: '#2ABBF8',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'URL', value: 'invoice.pdf' }],
|
||||
position: { x: 40, y: 80 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Extract invoice fields...' },
|
||||
],
|
||||
tools: [{ name: 'Textract', type: 'textract', bgColor: '#055F4E' }],
|
||||
position: { x: 400, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'supabase-1',
|
||||
name: 'Supabase',
|
||||
type: 'supabase',
|
||||
bgColor: '#1C1C1C',
|
||||
rows: [
|
||||
{ title: 'Table', value: 'invoices' },
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
],
|
||||
position: { x: 760, y: 80 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'agent-1', target: 'supabase-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Release Agent — GitHub → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const GITHUB_RELEASE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-github-release',
|
||||
name: 'GitHub Release Agent',
|
||||
color: '#00F701',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-1',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Release' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Summarize changelog...' },
|
||||
],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#releases' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-1', target: 'agent-2' },
|
||||
{ id: 'e-2', source: 'agent-2', target: 'slack-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting Follow-up Agent — Google Calendar → Agent → Gmail
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const MEETING_FOLLOWUP_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-meeting-followup',
|
||||
name: 'Meeting Follow-up Agent',
|
||||
color: '#FFCC02',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gcal-1',
|
||||
name: 'Google Calendar',
|
||||
type: 'google_calendar',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Meeting Ended' },
|
||||
{ title: 'Calendar', value: 'Work' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-3',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Draft follow-up email...' },
|
||||
],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'gmail-1',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Send Email' },
|
||||
{ title: 'To', value: 'attendees' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gcal-1', target: 'agent-3' },
|
||||
{ id: 'e-2', source: 'agent-3', target: 'gmail-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* CV/Resume Scanner — Start → Agent (Reducto) → Google Sheets
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const CV_SCANNER_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-cv-scanner',
|
||||
name: 'CV/Resume Scanner',
|
||||
color: '#FA4EDF',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-2',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'File URL', value: 'resume.pdf' }],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-4',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-opus-4.6' },
|
||||
{ title: 'System Prompt', value: 'Parse resume fields...' },
|
||||
],
|
||||
tools: [{ name: 'Reducto', type: 'reducto', bgColor: '#5c0c5c' }],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-1',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Candidates' },
|
||||
{ title: 'Operation', value: 'Append Row' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-2', target: 'agent-4' },
|
||||
{ id: 'e-2', source: 'agent-4', target: 'gsheets-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Email Triage Agent — Gmail → Agent (KB) → fan-out to Slack + Linear
|
||||
* Pattern: Fan-out (input low → agent mid → outputs spread vertically)
|
||||
*/
|
||||
const EMAIL_TRIAGE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-email-triage',
|
||||
name: 'Email Triage Agent',
|
||||
color: '#FF6B2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmail-2',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Email' },
|
||||
{ title: 'Label', value: 'Inbox' },
|
||||
],
|
||||
position: { x: 60, y: 130 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-5',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2-mini' },
|
||||
{ title: 'System Prompt', value: 'Classify and route...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'slack-2',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#urgent' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 20 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'linear-1',
|
||||
name: 'Linear',
|
||||
type: 'linear',
|
||||
bgColor: '#5E6AD2',
|
||||
rows: [
|
||||
{ title: 'Project', value: 'Support' },
|
||||
{ title: 'Operation', value: 'Create Issue' },
|
||||
],
|
||||
position: { x: 680, y: 200 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gmail-2', target: 'agent-5' },
|
||||
{ id: 'e-2', source: 'agent-5', target: 'slack-2' },
|
||||
{ id: 'e-3', source: 'agent-5', target: 'linear-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Competitor Monitor — Schedule → Agent (Firecrawl) → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const COMPETITOR_MONITOR_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-competitor-monitor',
|
||||
name: 'Competitor Monitor',
|
||||
color: '#6366F1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '08:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 50 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-6',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Monitor competitor...' },
|
||||
],
|
||||
tools: [{ name: 'Firecrawl', type: 'firecrawl', bgColor: '#181C1E' }],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'slack-3',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#competitive-intel' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 50 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-1', target: 'agent-6' },
|
||||
{ id: 'e-2', source: 'agent-6', target: 'slack-3' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Social Listening Agent — Schedule → Agent (Reddit + X) → Notion
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const SOCIAL_LISTENING_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-social-listening',
|
||||
name: 'Social Listening Agent',
|
||||
color: '#F43F5E',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-2',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [{ title: 'Run Frequency', value: 'Hourly' }],
|
||||
position: { x: 60, y: 150 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-7',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-flash' },
|
||||
{ title: 'System Prompt', value: 'Track brand mentions...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Reddit', type: 'reddit', bgColor: '#FF5700' },
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'notion-1',
|
||||
name: 'Notion',
|
||||
type: 'notion',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Database', value: 'Brand Mentions' },
|
||||
{ title: 'Operation', value: 'Create Page' },
|
||||
],
|
||||
position: { x: 680, y: 150 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-2', target: 'agent-7' },
|
||||
{ id: 'e-2', source: 'agent-7', target: 'notion-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Enrichment Pipeline — Start → Agent (LinkedIn) → Google Sheets
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const DATA_ENRICHMENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-data-enrichment',
|
||||
name: 'Data Enrichment Pipeline',
|
||||
color: '#14B8A6',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-3',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Email', value: 'lead@company.com' }],
|
||||
position: { x: 60, y: 55 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-8',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'mistral-large' },
|
||||
{ title: 'System Prompt', value: 'Enrich lead data...' },
|
||||
],
|
||||
tools: [{ name: 'LinkedIn', type: 'linkedin', bgColor: '#0072B1' }],
|
||||
position: { x: 370, y: 145 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-2',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Lead Database' },
|
||||
{ title: 'Operation', value: 'Update Row' },
|
||||
],
|
||||
position: { x: 680, y: 55 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-3', target: 'agent-8' },
|
||||
{ id: 'e-2', source: 'agent-8', target: 'gsheets-2' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Feedback Digest — Schedule → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const FEEDBACK_DIGEST_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-feedback-digest',
|
||||
name: 'Customer Feedback Digest',
|
||||
color: '#F59E0B',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-3',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-9',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Analyze customer feedback...' },
|
||||
],
|
||||
tools: [{ name: 'Airtable', type: 'airtable', bgColor: '#18BFFF' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-4',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#product-feedback' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-3', target: 'agent-9' },
|
||||
{ id: 'e-2', source: 'agent-9', target: 'slack-4' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* PR Review Agent — GitHub → Agent → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const PR_REVIEW_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-pr-review',
|
||||
name: 'PR Review Agent',
|
||||
color: '#06B6D4',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-2',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Pull Request Opened' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-10',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Review code changes...' },
|
||||
],
|
||||
position: { x: 370, y: 155 },
|
||||
},
|
||||
{
|
||||
id: 'slack-5',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#code-reviews' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-2', target: 'agent-10' },
|
||||
{ id: 'e-2', source: 'agent-10', target: 'slack-5' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge Base QA — Start → Agent (KB) → Response
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const KNOWLEDGE_QA_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-knowledge-qa',
|
||||
name: 'Knowledge Base QA',
|
||||
color: '#84CC16',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-4',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Question', value: 'How do I...' }],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-11',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Answer using knowledge...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'starter-5',
|
||||
name: 'Response',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Answer', value: 'Based on your docs...' }],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-4', target: 'agent-11' },
|
||||
{ id: 'e-2', source: 'agent-11', target: 'starter-5' },
|
||||
],
|
||||
}
|
||||
|
||||
export const TEMPLATE_WORKFLOWS: PreviewWorkflow[] = [
|
||||
OCR_INVOICE_WORKFLOW,
|
||||
GITHUB_RELEASE_WORKFLOW,
|
||||
MEETING_FOLLOWUP_WORKFLOW,
|
||||
CV_SCANNER_WORKFLOW,
|
||||
EMAIL_TRIAGE_WORKFLOW,
|
||||
COMPETITOR_MONITOR_WORKFLOW,
|
||||
SOCIAL_LISTENING_WORKFLOW,
|
||||
DATA_ENRICHMENT_WORKFLOW,
|
||||
FEEDBACK_DIGEST_WORKFLOW,
|
||||
PR_REVIEW_WORKFLOW,
|
||||
KNOWLEDGE_QA_WORKFLOW,
|
||||
]
|
||||
549
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
|
||||
|
||||
const LandingPreviewWorkflow = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
).then((mod) => mod.LandingPreviewWorkflow),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='h-full w-full bg-[#1b1b1b]' />,
|
||||
}
|
||||
)
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16)
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
const LEFT_WALL_CLIP = 'polygon(0 8px, 100% 0, 100% 100%, 0 100%)'
|
||||
const BOTTOM_WALL_CLIP = 'polygon(0 0, 100% 0, calc(100% - 8px) 100%, 0 100%)'
|
||||
|
||||
interface DepthConfig {
|
||||
color: string
|
||||
segments: readonly (readonly [opacity: number, width: number])[]
|
||||
}
|
||||
|
||||
/** Depth color and gradient segment pattern per template. Segments are `[opacity, width%]` tuples. */
|
||||
const DEPTH_CONFIGS: Record<string, DepthConfig> = {
|
||||
'tpl-ocr-invoice': {
|
||||
color: '#2ABBF8',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.5, 8],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 8],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.9, 7],
|
||||
[0.6, 12],
|
||||
[1, 8],
|
||||
[0.35, 8],
|
||||
],
|
||||
},
|
||||
'tpl-github-release': {
|
||||
color: '#00F701',
|
||||
segments: [
|
||||
[0.4, 8],
|
||||
[0.7, 6],
|
||||
[1, 5],
|
||||
[0.5, 14],
|
||||
[0.85, 8],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 7],
|
||||
[0.45, 8],
|
||||
[1, 8],
|
||||
[0.7, 8],
|
||||
],
|
||||
},
|
||||
'tpl-meeting-followup': {
|
||||
color: '#FFCC02',
|
||||
segments: [
|
||||
[0.5, 12],
|
||||
[0.8, 6],
|
||||
[0.35, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 14],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 8],
|
||||
[1, 6],
|
||||
[0.3, 8],
|
||||
],
|
||||
},
|
||||
'tpl-cv-scanner': {
|
||||
color: '#FA4EDF',
|
||||
segments: [
|
||||
[0.35, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 5],
|
||||
[1, 6],
|
||||
[0.4, 8],
|
||||
[0.75, 12],
|
||||
[0.5, 7],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.8, 8],
|
||||
[0.6, 9],
|
||||
[1, 6],
|
||||
[0.45, 8],
|
||||
],
|
||||
},
|
||||
'tpl-email-triage': {
|
||||
color: '#FF6B2C',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.7, 8],
|
||||
[1, 5],
|
||||
[0.5, 12],
|
||||
[0.85, 6],
|
||||
[0.3, 10],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 12],
|
||||
[1, 8],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-competitor-monitor': {
|
||||
color: '#6366F1',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.55, 10],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 7],
|
||||
[0.9, 8],
|
||||
[0.5, 10],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 6],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
],
|
||||
},
|
||||
'tpl-social-listening': {
|
||||
color: '#F43F5E',
|
||||
segments: [
|
||||
[0.5, 10],
|
||||
[0.8, 6],
|
||||
[0.4, 8],
|
||||
[1, 5],
|
||||
[0.6, 12],
|
||||
[0.35, 8],
|
||||
[0.9, 7],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.75, 8],
|
||||
[0.4, 6],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-data-enrichment': {
|
||||
color: '#14B8A6',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.6, 6],
|
||||
[0.9, 5],
|
||||
[0.4, 12],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 7],
|
||||
[0.85, 8],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.65, 8],
|
||||
[1, 7],
|
||||
[0.5, 8],
|
||||
],
|
||||
},
|
||||
'tpl-feedback-digest': {
|
||||
color: '#F59E0B',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.65, 6],
|
||||
[0.9, 5],
|
||||
[0.5, 12],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 7],
|
||||
[1, 5],
|
||||
[0.6, 10],
|
||||
[0.85, 8],
|
||||
[0.45, 6],
|
||||
[1, 8],
|
||||
[0.55, 9],
|
||||
],
|
||||
},
|
||||
'tpl-pr-review': {
|
||||
color: '#06B6D4',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.7, 7],
|
||||
[1, 5],
|
||||
[0.45, 10],
|
||||
[0.8, 6],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 10],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
[0.5, 7],
|
||||
],
|
||||
},
|
||||
'tpl-knowledge-qa': {
|
||||
color: '#84CC16',
|
||||
segments: [
|
||||
[0.5, 8],
|
||||
[0.75, 6],
|
||||
[0.4, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.85, 7],
|
||||
[0.35, 12],
|
||||
[1, 6],
|
||||
[0.7, 8],
|
||||
[0.45, 10],
|
||||
[0.9, 6],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const SCROLL_BLOCK_RX = '2.59574'
|
||||
|
||||
/**
|
||||
* Two-row horizontal block strip for the scroll-driven reveal in the templates section.
|
||||
* Same structural pattern as the hero's top-right blocks with matching colours:
|
||||
* blue (left) → pink (middle) → green (right).
|
||||
*/
|
||||
const SCROLL_BLOCK_RECTS = [
|
||||
{ opacity: 0.6, x: '-34.24', y: '0', width: '34.24', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '-17.38', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.34', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.24', y: '0', width: '34.24', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.24', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.62', y: '16.86', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.48', y: '0', width: '54.65', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '34.24', height: '33.73', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '51.10', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '123.65', y: '16.86', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '157.37', y: '0', width: '34.24', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.37', y: '0', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.0', y: '0', width: '68.48', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '209.14', y: '0', width: '16.86', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '243.23', y: '0', width: '34.24', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 1, x: '243.23', y: '0', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '260.10', y: '0', width: '34.04', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 1, x: '260.61', y: '16.86', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
] as const
|
||||
|
||||
const SCROLL_BLOCK_MAX_X = Math.max(...SCROLL_BLOCK_RECTS.map((r) => Number.parseFloat(r.x)))
|
||||
const SCROLL_REVEAL_START = 0.05
|
||||
const SCROLL_REVEAL_SPAN = 0.7
|
||||
const SCROLL_FADE_IN = 0.03
|
||||
|
||||
function getScrollBlockThreshold(x: string): number {
|
||||
const normalized = Number.parseFloat(x) / SCROLL_BLOCK_MAX_X
|
||||
return SCROLL_REVEAL_START + (1 - normalized) * SCROLL_REVEAL_SPAN
|
||||
}
|
||||
|
||||
interface ScrollBlockRectProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
rect: (typeof SCROLL_BLOCK_RECTS)[number]
|
||||
}
|
||||
|
||||
/** Renders a single SVG rect whose opacity is driven by scroll progress. */
|
||||
function ScrollBlockRect({ scrollYProgress, rect }: ScrollBlockRectProps) {
|
||||
const threshold = getScrollBlockThreshold(rect.x)
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[threshold, threshold + SCROLL_FADE_IN],
|
||||
[0, rect.opacity]
|
||||
)
|
||||
|
||||
return (
|
||||
<motion.rect
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx={SCROLL_BLOCK_RX}
|
||||
fill={rect.fill}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function buildBottomWallStyle(config: DepthConfig) {
|
||||
let pos = 0
|
||||
const stops: string[] = []
|
||||
for (const [opacity, width] of config.segments) {
|
||||
const c = hexToRgba(config.color, opacity)
|
||||
stops.push(`${c} ${pos}%`, `${c} ${pos + width}%`)
|
||||
pos += width
|
||||
}
|
||||
return {
|
||||
clipPath: BOTTOM_WALL_CLIP,
|
||||
background: `linear-gradient(135deg, ${stops.join(', ')})`,
|
||||
}
|
||||
}
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
rows: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TEMPLATES_PANEL_ID = 'templates-panel'
|
||||
|
||||
export default function Templates() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
|
||||
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id='templates'
|
||||
aria-labelledby='templates-heading'
|
||||
className='mt-[40px] mb-[80px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
|
||||
processing, release management, meeting follow-ups, resume scanning, email triage,
|
||||
competitor monitoring, social listening, data enrichment, feedback analysis, code review,
|
||||
and knowledge base Q&A. Each template connects real integrations and LLMs — pick one,
|
||||
customise it, and deploy in minutes.
|
||||
</p>
|
||||
|
||||
<div className='bg-[#1C1C1C]'>
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='relative overflow-hidden'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-0 z-20 hidden lg:block'
|
||||
>
|
||||
<svg
|
||||
width={329}
|
||||
height={34}
|
||||
viewBox='-34 0 329 34'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
>
|
||||
{SCROLL_BLOCK_RECTS.map((r, i) => (
|
||||
<ScrollBlockRect key={i} scrollYProgress={scrollYProgress} rect={r} />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='px-[80px] pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
|
||||
style={{
|
||||
color: activeDepth.color,
|
||||
backgroundColor: hexToRgba(activeDepth.color, 0.1),
|
||||
}}
|
||||
>
|
||||
Templates
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='templates-heading'
|
||||
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
Pre-built templates for every use case—pick one, swap <br />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[73px] flex border-[#2A2A2A] border-y'>
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-r p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='flex min-w-0 flex-1'>
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Workflow templates'
|
||||
className='flex w-[300px] shrink-0 flex-col border-[#2A2A2A] border-r'
|
||||
>
|
||||
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
|
||||
const isActive = index === activeIndex
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
id={`template-tab-${index}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
aria-controls={TEMPLATES_PANEL_ID}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
'relative text-left',
|
||||
isActive
|
||||
? 'z-10'
|
||||
: 'flex items-center px-[12px] py-[10px] shadow-[inset_0_-1px_0_0_#2A2A2A] last:shadow-none hover:bg-[#232323]/50'
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
(() => {
|
||||
const depth = DEPTH_CONFIGS[workflow.id]
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute top-[-8px] bottom-0 left-0 w-2'
|
||||
style={{
|
||||
clipPath: LEFT_WALL_CLIP,
|
||||
backgroundColor: hexToRgba(depth.color, 0.63),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute right-[-8px] bottom-0 left-2 h-2'
|
||||
style={buildBottomWallStyle(depth)}
|
||||
/>
|
||||
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
|
||||
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='-rotate-90 h-[11px] w-[11px] shrink-0'
|
||||
style={{ color: depth.color }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={TEMPLATES_PANEL_ID}
|
||||
role='tabpanel'
|
||||
aria-labelledby={`template-tab-${activeIndex}`}
|
||||
className='relative hidden flex-1 lg:block'
|
||||
>
|
||||
<div aria-hidden='true' className='h-full'>
|
||||
<LandingPreviewWorkflow
|
||||
key={activeIndex}
|
||||
workflow={activeWorkflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
Use template
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/components/testimonials/testimonials.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Testimonials section — social proof via user quotes.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="testimonials" aria-labelledby="testimonials-heading">`.
|
||||
* - `<h2 id="testimonials-heading">` for the section title.
|
||||
* - Each testimonial: `<blockquote cite="tweet-url">` with `<footer><cite>Author</cite></footer>`.
|
||||
* - Profile images use `loading="lazy"` (below the fold).
|
||||
*
|
||||
* GEO:
|
||||
* - Keep quote text as plain text in `<blockquote>` — not split across `<span>` elements.
|
||||
* - Include full author name + handle (LLMs weigh attributed quotes higher).
|
||||
* - Testimonials mentioning "Sim" by name carry more citation weight.
|
||||
* - Review data here aligns with `review` entries in structured-data.tsx.
|
||||
*/
|
||||
export default function Testimonials() {
|
||||
return null
|
||||
}
|
||||
53
apps/sim/app/(home)/landing.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
Features,
|
||||
Footer,
|
||||
Hero,
|
||||
Navbar,
|
||||
Pricing,
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
} from '@/app/(home)/components'
|
||||
|
||||
/**
|
||||
* Landing page root component.
|
||||
*
|
||||
* ## SEO Architecture
|
||||
* - Single `<h1>` inside Hero (only one per page).
|
||||
* - Heading hierarchy: H1 (Hero) -> H2 (each section) -> H3 (sub-items).
|
||||
* - Semantic landmarks: `<header>`, `<main>`, `<footer>`.
|
||||
* - Every `<section>` has an `id` for anchor linking and `aria-labelledby` for accessibility.
|
||||
* - `StructuredData` emits JSON-LD before any visible content.
|
||||
*
|
||||
* ## GEO Architecture
|
||||
* - Above-fold content (Navbar, Hero) is statically rendered (Server Components where possible)
|
||||
* for immediate availability to AI crawlers.
|
||||
* - Section `id` attributes serve as fragment anchors for precise AI citations.
|
||||
* - Content ordering prioritizes answer-first patterns: definition (Hero) ->
|
||||
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration, Testimonials) ->
|
||||
* pricing (Pricing) -> enterprise (Enterprise).
|
||||
*/
|
||||
export default async function Landing() {
|
||||
return (
|
||||
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
|
||||
<StructuredData />
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
<main>
|
||||
<Hero />
|
||||
<Templates />
|
||||
<Features />
|
||||
<Collaboration />
|
||||
<Pricing />
|
||||
<Enterprise />
|
||||
<Testimonials />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/layout.tsx
Normal file
@@ -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>
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export default function StructuredData() {
|
||||
name: 'Sim',
|
||||
alternateName: 'Sim',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by developers at trail-blazing startups to Fortune 500 companies',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
url: 'https://sim.ai',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
@@ -36,9 +36,9 @@ export default function StructuredData() {
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://sim.ai/#website',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim - AI Agent Workflow Builder',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows. SOC2 and HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
|
||||
publisher: {
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
},
|
||||
@@ -48,7 +48,7 @@ export default function StructuredData() {
|
||||
'@type': 'WebPage',
|
||||
'@id': 'https://sim.ai/#webpage',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim - Workflows for LLMs | Build AI Agent Workflows',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
isPartOf: {
|
||||
'@id': 'https://sim.ai/#website',
|
||||
},
|
||||
@@ -58,7 +58,7 @@ export default function StructuredData() {
|
||||
datePublished: '2024-01-01T00:00:00+00:00',
|
||||
dateModified: new Date().toISOString(),
|
||||
description:
|
||||
'Build and deploy AI agent workflows with Sim. Visual drag-and-drop interface for creating powerful LLM-powered automations.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
breadcrumb: {
|
||||
'@id': 'https://sim.ai/#breadcrumb',
|
||||
},
|
||||
@@ -85,9 +85,9 @@ export default function StructuredData() {
|
||||
{
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': 'https://sim.ai/#software',
|
||||
name: 'Sim - AI Agent Workflow Builder',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by 60,000+ developers. Build agentic workflows with visual drag-and-drop interface. SOC2 and HIPAA compliant. Integrate with 100+ apps.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'AI Development Tools',
|
||||
operatingSystem: 'Web, Windows, macOS, Linux',
|
||||
@@ -159,12 +159,13 @@ export default function StructuredData() {
|
||||
worstRating: '1',
|
||||
},
|
||||
featureList: [
|
||||
'Visual workflow builder',
|
||||
'Drag-and-drop interface',
|
||||
'100+ integrations',
|
||||
'AI model support (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Real-time collaboration',
|
||||
'Version control',
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
@@ -174,7 +175,7 @@ export default function StructuredData() {
|
||||
{
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://sim.ai/logo/426-240/primary/small.png',
|
||||
caption: 'Sim AI agent workflow builder interface',
|
||||
caption: 'Sim — build AI agents and run your agentic workforce',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -187,7 +188,7 @@ export default function StructuredData() {
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is an open-source AI agent workflow builder used by 60,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -203,7 +204,7 @@ export default function StructuredData() {
|
||||
name: 'Do I need coding skills to use Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'No coding skills are required! Sim features a visual drag-and-drop interface that makes it easy to build AI workflows. However, developers can also use custom functions and our API for advanced use cases.',
|
||||
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname.startsWith('/chat') ||
|
||||
pathname.startsWith('/studio') ||
|
||||
pathname.startsWith('/resume') ||
|
||||
pathname.startsWith('/form')
|
||||
pathname.startsWith('/form') ||
|
||||
pathname.startsWith('/oauth')
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
|
||||
14
apps/sim/app/_styles/fonts/martian-mono/martian-mono.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Martian_Mono } from 'next/font/google'
|
||||
|
||||
/**
|
||||
* Martian Mono font configuration
|
||||
* Monospaced variable font used for code snippets, technical content, and accent text
|
||||
* on the landing page. Supports weights 100-800.
|
||||
*/
|
||||
export const martianMono = Martian_Mono({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-martian-mono',
|
||||
weight: 'variable',
|
||||
fallback: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||
})
|
||||
59
apps/sim/app/api/auth/oauth2/authorize-params/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { db } from '@sim/db'
|
||||
import { verification } from '@sim/db/schema'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Returns the original OAuth authorize parameters stored in the verification record
|
||||
* for a given consent code. Used by the consent page to reconstruct the authorize URL
|
||||
* when switching accounts.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const consentCode = request.nextUrl.searchParams.get('consent_code')
|
||||
if (!consentCode) {
|
||||
return NextResponse.json({ error: 'consent_code is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [record] = await db
|
||||
.select({ value: verification.value })
|
||||
.from(verification)
|
||||
.where(and(eq(verification.identifier, consentCode), gt(verification.expiresAt, new Date())))
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
return NextResponse.json({ error: 'Invalid or expired consent code' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data = JSON.parse(record.value) as {
|
||||
clientId: string
|
||||
redirectURI: string
|
||||
scope: string[]
|
||||
userId: string
|
||||
codeChallenge: string
|
||||
codeChallengeMethod: string
|
||||
state: string | null
|
||||
nonce: string | null
|
||||
}
|
||||
|
||||
if (data.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
client_id: data.clientId,
|
||||
redirect_uri: data.redirectURI,
|
||||
scope: data.scope.join(' '),
|
||||
code_challenge: data.codeChallenge,
|
||||
code_challenge_method: data.codeChallengeMethod,
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
response_type: 'code',
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import {
|
||||
ORCHESTRATION_TIMEOUT_MS,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
resolveWorkflowIdForUser,
|
||||
@@ -384,12 +386,14 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
...(tool.annotations && { annotations: tool.annotations }),
|
||||
}))
|
||||
|
||||
const subagentTools = SUBAGENT_TOOL_DEFS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
...(tool.annotations && { annotations: tool.annotations }),
|
||||
}))
|
||||
|
||||
const result: ListToolsResult = {
|
||||
@@ -402,27 +406,51 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
||||
const headers = (extra.requestInfo?.headers || {}) as HeaderMap
|
||||
const apiKeyHeader = readHeader(headers, 'x-api-key')
|
||||
const authorizationHeader = readHeader(headers, 'authorization')
|
||||
|
||||
if (!apiKeyHeader) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'AUTHENTICATION ERROR: No Copilot API key provided. The user must set their Copilot API key in the x-api-key header. They can generate one in the Sim app under Settings → Copilot. Do NOT retry — this will fail until the key is configured.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
let authResult: CopilotKeyAuthResult = { success: false }
|
||||
|
||||
if (authorizationHeader?.startsWith('Bearer ')) {
|
||||
const token = authorizationHeader.slice(7)
|
||||
const oauthResult = await validateOAuthAccessToken(token)
|
||||
if (oauthResult.success && oauthResult.userId) {
|
||||
if (!oauthResult.scopes?.includes('mcp:tools')) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'AUTHENTICATION ERROR: OAuth token is missing the required "mcp:tools" scope. Re-authorize with the correct scopes.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
authResult = { success: true, userId: oauthResult.userId }
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `AUTHENTICATION ERROR: ${oauthResult.error ?? 'Invalid OAuth access token'} Do NOT retry — re-authorize via OAuth.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
} else if (apiKeyHeader) {
|
||||
authResult = await authenticateCopilotApiKey(apiKeyHeader)
|
||||
}
|
||||
|
||||
const authResult = await authenticateCopilotApiKey(apiKeyHeader)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn('MCP copilot key auth failed', { method: request.method })
|
||||
const errorMsg = apiKeyHeader
|
||||
? `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`
|
||||
: 'AUTHENTICATION ERROR: No authentication provided. Provide a Bearer token (OAuth 2.1) or an x-api-key header. Generate a Copilot API key in Settings → Copilot.'
|
||||
logger.warn('MCP copilot auth failed', { method: request.method })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`,
|
||||
text: errorMsg,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
@@ -512,6 +540,20 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key')
|
||||
|
||||
if (!hasAuth) {
|
||||
const origin = getBaseUrl().replace(/\/$/, '')
|
||||
const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource/api/mcp/copilot`
|
||||
return new NextResponse(JSON.stringify({ error: 'unauthorized' }), {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
let parsedBody: unknown
|
||||
|
||||
@@ -532,6 +574,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
void request
|
||||
return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 })
|
||||
|
||||
96
apps/sim/app/api/tools/slack/send-ephemeral/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackSendEphemeralAPI')
|
||||
|
||||
const SlackSendEphemeralSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
channel: z.string().min(1, 'Channel ID is required'),
|
||||
user: z.string().min(1, 'User ID is required'),
|
||||
text: z.string().min(1, 'Message text is required'),
|
||||
thread_ts: z.string().optional().nullable(),
|
||||
blocks: z.array(z.record(z.unknown())).optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Slack ephemeral send attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated Slack ephemeral send request via ${authResult.authType}`,
|
||||
{ userId: authResult.userId }
|
||||
)
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = SlackSendEphemeralSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Sending ephemeral message`, {
|
||||
channel: validatedData.channel,
|
||||
user: validatedData.user,
|
||||
threadTs: validatedData.thread_ts ?? undefined,
|
||||
})
|
||||
|
||||
const response = await fetch('https://slack.com/api/chat.postEphemeral', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
user: validatedData.user,
|
||||
text: validatedData.text,
|
||||
...(validatedData.thread_ts && { thread_ts: validatedData.thread_ts }),
|
||||
...(validatedData.blocks &&
|
||||
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API error:`, data.error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.error || 'Failed to send ephemeral message' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Ephemeral message sent successfully`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
messageTs: data.message_ts,
|
||||
channel: validatedData.channel,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error sending ephemeral message:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const SlackSendMessageSchema = z
|
||||
userId: z.string().optional().nullable(),
|
||||
text: z.string().min(1, 'Message text is required'),
|
||||
thread_ts: z.string().optional().nullable(),
|
||||
blocks: z.array(z.record(z.unknown())).optional().nullable(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
.refine((data) => data.channel || data.userId, {
|
||||
@@ -63,6 +64,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: validatedData.userId ?? undefined,
|
||||
text: validatedData.text,
|
||||
threadTs: validatedData.thread_ts ?? undefined,
|
||||
blocks: validatedData.blocks ?? undefined,
|
||||
files: validatedData.files ?? undefined,
|
||||
},
|
||||
requestId,
|
||||
|
||||
@@ -13,6 +13,7 @@ const SlackUpdateMessageSchema = z.object({
|
||||
channel: z.string().min(1, 'Channel is required'),
|
||||
timestamp: z.string().min(1, 'Message timestamp is required'),
|
||||
text: z.string().min(1, 'Message text is required'),
|
||||
blocks: z.array(z.record(z.unknown())).optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -57,6 +58,8 @@ export async function POST(request: NextRequest) {
|
||||
channel: validatedData.channel,
|
||||
ts: validatedData.timestamp,
|
||||
text: validatedData.text,
|
||||
...(validatedData.blocks &&
|
||||
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ export async function postSlackMessage(
|
||||
accessToken: string,
|
||||
channel: string,
|
||||
text: string,
|
||||
threadTs?: string | null
|
||||
threadTs?: string | null,
|
||||
blocks?: unknown[] | null
|
||||
): Promise<{ ok: boolean; ts?: string; channel?: string; message?: any; error?: string }> {
|
||||
const response = await fetch('https://slack.com/api/chat.postMessage', {
|
||||
method: 'POST',
|
||||
@@ -23,6 +24,7 @@ export async function postSlackMessage(
|
||||
channel,
|
||||
text,
|
||||
...(threadTs && { thread_ts: threadTs }),
|
||||
...(blocks && blocks.length > 0 && { blocks }),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -220,6 +222,7 @@ export interface SlackMessageParams {
|
||||
userId?: string
|
||||
text: string
|
||||
threadTs?: string | null
|
||||
blocks?: unknown[] | null
|
||||
files?: any[] | null
|
||||
}
|
||||
|
||||
@@ -242,7 +245,7 @@ export async function sendSlackMessage(
|
||||
}
|
||||
error?: string
|
||||
}> {
|
||||
const { accessToken, text, threadTs, files } = params
|
||||
const { accessToken, text, threadTs, blocks, files } = params
|
||||
let { channel } = params
|
||||
|
||||
if (!channel && params.userId) {
|
||||
@@ -258,7 +261,7 @@ export async function sendSlackMessage(
|
||||
if (!files || files.length === 0) {
|
||||
logger.info(`[${requestId}] No files, using chat.postMessage`)
|
||||
|
||||
const data = await postSlackMessage(accessToken, channel, text, threadTs)
|
||||
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API error:`, data.error)
|
||||
@@ -282,7 +285,7 @@ export async function sendSlackMessage(
|
||||
if (fileIds.length === 0) {
|
||||
logger.warn(`[${requestId}] No valid files to upload, sending text-only message`)
|
||||
|
||||
const data = await postSlackMessage(accessToken, channel, text, threadTs)
|
||||
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
|
||||
|
||||
if (!data.ok) {
|
||||
return { success: false, error: data.error || 'Failed to send message' }
|
||||
|
||||
@@ -165,7 +165,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const modelName =
|
||||
provider === 'anthropic' ? 'anthropic/claude-3-7-sonnet-latest' : 'openai/gpt-4.1'
|
||||
provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5'
|
||||
|
||||
try {
|
||||
logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName })
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const modelName =
|
||||
provider === 'anthropic' ? 'anthropic/claude-3-7-sonnet-latest' : 'openai/gpt-4.1'
|
||||
provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5'
|
||||
|
||||
logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName })
|
||||
|
||||
|
||||
@@ -3,18 +3,18 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
export async function GET() {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const llmsFullContent = `# Sim - AI Agent Workflow Builder
|
||||
const llmsFullContent = `# Sim — Build AI Agents & Run Your Agentic Workforce
|
||||
|
||||
> Sim is an open-source AI agent workflow builder used by 60,000+ developers at startups to Fortune 500 companies. Build and deploy agentic workflows with a visual drag-and-drop canvas. SOC2 and HIPAA compliant.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
Sim provides a visual interface for building AI agent workflows. Instead of writing code, users drag and drop blocks onto a canvas and connect them to create complex AI automations. Each block represents a step in the workflow - an LLM call, a tool invocation, an API request, or a code execution.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that execute real workflows across systems, and manage them with full observability. SOC2 and HIPAA compliant.
|
||||
|
||||
## Product Details
|
||||
|
||||
- **Product Name**: Sim
|
||||
- **Category**: AI Development Tools / Workflow Automation
|
||||
- **Category**: AI Agent Platform / Agentic Workflow Orchestration
|
||||
- **Deployment**: Cloud (SaaS) and Self-hosted options
|
||||
- **Pricing**: Free tier, Pro ($20/month), Team ($40/month), Enterprise (custom)
|
||||
- **Compliance**: SOC2 Type II, HIPAA compliant
|
||||
@@ -66,7 +66,7 @@ Sim supports all major LLM providers:
|
||||
- Amazon Bedrock
|
||||
|
||||
### Integrations
|
||||
100+ pre-built integrations including:
|
||||
1,000+ pre-built integrations including:
|
||||
- **Communication**: Slack, Discord, Email (Gmail, Outlook), SMS (Twilio)
|
||||
- **Productivity**: Notion, Airtable, Google Sheets, Google Docs
|
||||
- **Development**: GitHub, GitLab, Jira, Linear
|
||||
@@ -81,6 +81,12 @@ Built-in support for:
|
||||
- Semantic search and retrieval
|
||||
- Chunking strategies (fixed size, semantic, recursive)
|
||||
|
||||
### Tables
|
||||
Built-in table creation and management:
|
||||
- Structured data storage
|
||||
- Queryable tables for agent workflows
|
||||
- Native integrations
|
||||
|
||||
### Code Execution
|
||||
- Sandboxed JavaScript/TypeScript execution
|
||||
- Access to npm packages
|
||||
|
||||
@@ -5,16 +5,16 @@ export async function GET() {
|
||||
|
||||
const llmsContent = `# Sim
|
||||
|
||||
> Sim is an open-source AI agent workflow builder. 60,000+ developers at startups to Fortune 500 companies deploy agentic workflows on the Sim platform. SOC2 and HIPAA compliant.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
Sim provides a visual drag-and-drop interface for building and deploying AI agent workflows. Connect to 100+ integrations and ship production-ready AI automations.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 and HIPAA compliant.
|
||||
|
||||
## Core Pages
|
||||
|
||||
- [Homepage](${baseUrl}): Main landing page with product overview and features
|
||||
- [Homepage](${baseUrl}): Product overview, features, and pricing
|
||||
- [Templates](${baseUrl}/templates): Pre-built workflow templates to get started quickly
|
||||
- [Changelog](${baseUrl}/changelog): Product updates and release notes
|
||||
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides for AI workflows
|
||||
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -29,28 +29,31 @@ Sim provides a visual drag-and-drop interface for building and deploying AI agen
|
||||
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
|
||||
- **Trigger**: Event or schedule that initiates workflow execution
|
||||
- **Execution**: A single run of a workflow with logs and outputs
|
||||
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Visual workflow builder with drag-and-drop canvas
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI)
|
||||
- Retrieval-augmented generation (RAG) with vector databases
|
||||
- 100+ integrations (Slack, Gmail, Notion, Airtable, databases)
|
||||
- AI agent creation and deployment
|
||||
- Agentic workflow orchestration
|
||||
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
|
||||
- Knowledge base creation with retrieval-augmented generation (RAG)
|
||||
- Table creation and management
|
||||
- Document creation and processing
|
||||
- Scheduled and webhook-triggered executions
|
||||
- Real-time collaboration and version control
|
||||
|
||||
## Use Cases
|
||||
|
||||
- AI agent workflow automation
|
||||
- RAG pipelines and document processing
|
||||
- Chatbot and copilot workflows for SaaS
|
||||
- Email and customer support automation
|
||||
- AI agent deployment and orchestration
|
||||
- Knowledge bases and RAG pipelines
|
||||
- Document creation and processing
|
||||
- Customer support automation
|
||||
- Internal operations (sales, marketing, legal, finance)
|
||||
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with users
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
|
||||
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
|
||||
|
||||
## Optional
|
||||
|
||||
@@ -5,10 +5,10 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return {
|
||||
name: brand.name === 'Sim' ? 'Sim - AI Agent Workflow Builder' : brand.name,
|
||||
name: brand.name === 'Sim' ? 'Sim — Build AI Agents & Run Your Agentic Workforce' : brand.name,
|
||||
short_name: brand.name,
|
||||
description:
|
||||
'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. SOC2 and HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Landing from '@/app/(landing)/landing'
|
||||
import Landing from '@/app/(home)/landing'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source Platform',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by 60,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
keywords:
|
||||
'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI',
|
||||
'AI agents, agentic workforce, open-source AI agent platform, agentic workflows, LLM orchestration, AI automation, knowledge base, workflow builder, AI integrations, SOC2 compliant, HIPAA compliant, enterprise AI',
|
||||
authors: [{ name: 'Sim' }],
|
||||
creator: 'Sim',
|
||||
publisher: 'Sim',
|
||||
@@ -20,9 +22,9 @@ export const metadata: Metadata = {
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source platform used by 60,000+ developers. Design, deploy, and monitor agentic workflows with a visual drag-and-drop interface, 100+ integrations, and enterprise-grade security.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Join over 100,000 builders.',
|
||||
type: 'website',
|
||||
url: baseUrl,
|
||||
siteName: 'Sim',
|
||||
@@ -32,7 +34,7 @@ export const metadata: Metadata = {
|
||||
url: '/logo/426-240/primary/small.png',
|
||||
width: 2130,
|
||||
height: 1200,
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
@@ -41,12 +43,12 @@ export const metadata: Metadata = {
|
||||
card: 'summary_large_image',
|
||||
site: '@simdotai',
|
||||
creator: '@simdotai',
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
images: {
|
||||
url: '/logo/426-240/primary/small.png',
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
@@ -72,11 +74,12 @@ export const metadata: Metadata = {
|
||||
classification: 'AI Development Tools',
|
||||
referrer: 'origin-when-cross-origin',
|
||||
other: {
|
||||
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
|
||||
'llm:content-type':
|
||||
'AI agent platform, agentic workforce, agentic workflows, LLM orchestration',
|
||||
'llm:use-cases':
|
||||
'email automation, Slack bots, Discord moderation, data analysis, customer support, content generation, agentic automations',
|
||||
'AI agents, agentic workforce, agentic workflows, knowledge bases, tables, document creation, email automation, Slack bots, data analysis, customer support, content generation',
|
||||
'llm:integrations':
|
||||
'OpenAI, Anthropic, Google AI, Slack, Gmail, Discord, Notion, Airtable, Supabase',
|
||||
'OpenAI, Anthropic, Google AI, Mistral, xAI, Perplexity, Slack, Gmail, Discord, Notion, Airtable, Supabase',
|
||||
'llm:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
|
||||
'llm:region': 'global',
|
||||
'llm:languages': 'en',
|
||||
|
||||
@@ -208,9 +208,10 @@ export default function Logs() {
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
if (!selectedLogFromList) return null
|
||||
if (!activeLogQuery.data || isPreviewOpen) return selectedLogFromList
|
||||
if (!activeLogQuery.data || isPreviewOpen || activeLogQuery.isPlaceholderData)
|
||||
return selectedLogFromList
|
||||
return { ...selectedLogFromList, ...activeLogQuery.data }
|
||||
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
|
||||
}, [selectedLogFromList, activeLogQuery.data, activeLogQuery.isPlaceholderData, isPreviewOpen])
|
||||
|
||||
const handleLogHover = useCallback(
|
||||
(log: WorkflowLog) => {
|
||||
@@ -650,7 +651,7 @@ export default function Logs() {
|
||||
hasActiveFilters={filtersActive}
|
||||
/>
|
||||
|
||||
{isPreviewOpen && activeLogQuery.data?.executionId && (
|
||||
{isPreviewOpen && !activeLogQuery.isPlaceholderData && activeLogQuery.data?.executionId && (
|
||||
<ExecutionSnapshot
|
||||
executionId={activeLogQuery.data.executionId}
|
||||
traceSpans={activeLogQuery.data.executionData?.traceSpans}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -42,44 +43,10 @@ export function useChangeDetection({
|
||||
const currentState = useMemo((): WorkflowState | null => {
|
||||
if (!workflowId) return null
|
||||
|
||||
const blocksWithSubBlocks: WorkflowState['blocks'] = {}
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
const blockSubValues = subBlockValues?.[blockId] || {}
|
||||
const subBlocks: Record<string, any> = {}
|
||||
|
||||
if (block.subBlocks) {
|
||||
for (const [subId, subBlock] of Object.entries(block.subBlocks)) {
|
||||
const storedValue = blockSubValues[subId]
|
||||
subBlocks[subId] = {
|
||||
...subBlock,
|
||||
value: storedValue !== undefined ? storedValue : subBlock.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (block.triggerMode) {
|
||||
const triggerConfigValue = blockSubValues?.triggerConfig
|
||||
if (
|
||||
triggerConfigValue &&
|
||||
typeof triggerConfigValue === 'object' &&
|
||||
!subBlocks.triggerConfig
|
||||
) {
|
||||
subBlocks.triggerConfig = {
|
||||
id: 'triggerConfig',
|
||||
type: 'short-input',
|
||||
value: triggerConfigValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blocksWithSubBlocks[blockId] = {
|
||||
...block,
|
||||
subBlocks,
|
||||
}
|
||||
}
|
||||
const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {})
|
||||
|
||||
return {
|
||||
blocks: blocksWithSubBlocks,
|
||||
blocks: mergedBlocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
|
||||
@@ -33,6 +33,7 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Browser Use LLM', id: 'browser-use-llm' },
|
||||
{ label: 'Browser Use 2.0', id: 'browser-use-2.0' },
|
||||
{ label: 'GPT-4o', id: 'gpt-4o' },
|
||||
{ label: 'GPT-4o Mini', id: 'gpt-4o-mini' },
|
||||
{ label: 'GPT-4.1', id: 'gpt-4.1' },
|
||||
@@ -42,6 +43,7 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
|
||||
{ label: 'Gemini 2.5 Flash', id: 'gemini-2.5-flash' },
|
||||
{ label: 'Gemini 2.5 Pro', id: 'gemini-2.5-pro' },
|
||||
{ label: 'Gemini 3 Pro Preview', id: 'gemini-3-pro-preview' },
|
||||
{ label: 'Gemini 3 Flash Preview', id: 'gemini-3-flash-preview' },
|
||||
{ label: 'Gemini Flash Latest', id: 'gemini-flash-latest' },
|
||||
{ label: 'Gemini Flash Lite Latest', id: 'gemini-flash-lite-latest' },
|
||||
{ label: 'Claude 3.7 Sonnet', id: 'claude-3-7-sonnet-20250219' },
|
||||
|
||||
446
apps/sim/blocks/blocks/hex.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { HexIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { HexResponse } from '@/tools/hex/types'
|
||||
|
||||
export const HexBlock: BlockConfig<HexResponse> = {
|
||||
type: 'hex',
|
||||
name: 'Hex',
|
||||
description: 'Run and manage Hex projects',
|
||||
longDescription:
|
||||
'Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.',
|
||||
docsLink: 'https://docs.sim.ai/tools/hex',
|
||||
category: 'tools',
|
||||
bgColor: '#F5E6FF',
|
||||
icon: HexIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Run Project', id: 'run_project' },
|
||||
{ label: 'Get Run Status', id: 'get_run_status' },
|
||||
{ label: 'Get Project Runs', id: 'get_project_runs' },
|
||||
{ label: 'Cancel Run', id: 'cancel_run' },
|
||||
{ label: 'List Projects', id: 'list_projects' },
|
||||
{ label: 'Get Project', id: 'get_project' },
|
||||
{ label: 'Update Project', id: 'update_project' },
|
||||
{ label: 'Get Queried Tables', id: 'get_queried_tables' },
|
||||
{ label: 'List Users', id: 'list_users' },
|
||||
{ label: 'List Groups', id: 'list_groups' },
|
||||
{ label: 'Get Group', id: 'get_group' },
|
||||
{ label: 'List Collections', id: 'list_collections' },
|
||||
{ label: 'Get Collection', id: 'get_collection' },
|
||||
{ label: 'Create Collection', id: 'create_collection' },
|
||||
{ label: 'List Data Connections', id: 'list_data_connections' },
|
||||
{ label: 'Get Data Connection', id: 'get_data_connection' },
|
||||
],
|
||||
value: () => 'run_project',
|
||||
},
|
||||
{
|
||||
id: 'projectId',
|
||||
title: 'Project ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter project UUID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'run_project',
|
||||
'get_run_status',
|
||||
'get_project_runs',
|
||||
'cancel_run',
|
||||
'get_project',
|
||||
'update_project',
|
||||
'get_queried_tables',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'run_project',
|
||||
'get_run_status',
|
||||
'get_project_runs',
|
||||
'cancel_run',
|
||||
'get_project',
|
||||
'update_project',
|
||||
'get_queried_tables',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'runId',
|
||||
title: 'Run ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter run UUID',
|
||||
condition: { field: 'operation', value: ['get_run_status', 'cancel_run'] },
|
||||
required: { field: 'operation', value: ['get_run_status', 'cancel_run'] },
|
||||
},
|
||||
{
|
||||
id: 'inputParams',
|
||||
title: 'Input Parameters',
|
||||
type: 'code',
|
||||
placeholder: '{"param_name": "value"}',
|
||||
condition: { field: 'operation', value: 'run_project' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert at creating Hex project input parameters.
|
||||
Generate ONLY the raw JSON object based on the user's request.
|
||||
The output MUST be a single, valid JSON object, starting with { and ending with }.
|
||||
|
||||
Current parameters: {context}
|
||||
|
||||
Do not include any explanations, markdown formatting, or other text outside the JSON object.
|
||||
The keys should match the input parameter names defined in the Hex project.
|
||||
|
||||
Example:
|
||||
{
|
||||
"date_range": "2024-01-01",
|
||||
"department": "engineering",
|
||||
"include_inactive": false
|
||||
}`,
|
||||
placeholder: 'Describe the input parameters you need...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projectStatus',
|
||||
title: 'Status',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter status name (e.g., custom workspace status label)',
|
||||
condition: { field: 'operation', value: 'update_project' },
|
||||
required: { field: 'operation', value: 'update_project' },
|
||||
},
|
||||
{
|
||||
id: 'runStatusFilter',
|
||||
title: 'Status Filter',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Pending', id: 'PENDING' },
|
||||
{ label: 'Running', id: 'RUNNING' },
|
||||
{ label: 'Completed', id: 'COMPLETED' },
|
||||
{ label: 'Errored', id: 'ERRORED' },
|
||||
{ label: 'Killed', id: 'KILLED' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'get_project_runs' },
|
||||
},
|
||||
{
|
||||
id: 'groupIdInput',
|
||||
title: 'Group ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter group UUID',
|
||||
condition: { field: 'operation', value: 'get_group' },
|
||||
required: { field: 'operation', value: 'get_group' },
|
||||
},
|
||||
{
|
||||
id: 'collectionId',
|
||||
title: 'Collection ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter collection UUID',
|
||||
condition: { field: 'operation', value: 'get_collection' },
|
||||
required: { field: 'operation', value: 'get_collection' },
|
||||
},
|
||||
{
|
||||
id: 'collectionName',
|
||||
title: 'Collection Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter collection name',
|
||||
condition: { field: 'operation', value: 'create_collection' },
|
||||
required: { field: 'operation', value: 'create_collection' },
|
||||
},
|
||||
{
|
||||
id: 'collectionDescription',
|
||||
title: 'Description',
|
||||
type: 'long-input',
|
||||
placeholder: 'Optional description for the collection',
|
||||
condition: { field: 'operation', value: 'create_collection' },
|
||||
},
|
||||
{
|
||||
id: 'dataConnectionId',
|
||||
title: 'Data Connection ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter data connection UUID',
|
||||
condition: { field: 'operation', value: 'get_data_connection' },
|
||||
required: { field: 'operation', value: 'get_data_connection' },
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Hex API token',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
// Advanced fields
|
||||
{
|
||||
id: 'dryRun',
|
||||
title: 'Dry Run',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'run_project' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'updateCache',
|
||||
title: 'Update Cache',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'run_project' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'updatePublishedResults',
|
||||
title: 'Update Published Results',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'run_project' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'useCachedSqlResults',
|
||||
title: 'Use Cached SQL Results',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'run_project' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '25',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'list_projects',
|
||||
'get_project_runs',
|
||||
'get_queried_tables',
|
||||
'list_users',
|
||||
'list_groups',
|
||||
'list_collections',
|
||||
'list_data_connections',
|
||||
],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'offset',
|
||||
title: 'Offset',
|
||||
type: 'short-input',
|
||||
placeholder: '0',
|
||||
condition: { field: 'operation', value: 'get_project_runs' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'includeArchived',
|
||||
title: 'Include Archived',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'list_projects' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'statusFilter',
|
||||
title: 'Status Filter',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Published', id: 'PUBLISHED' },
|
||||
{ label: 'Draft', id: 'DRAFT' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'list_projects' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'groupId',
|
||||
title: 'Filter by Group',
|
||||
type: 'short-input',
|
||||
placeholder: 'Group UUID (optional)',
|
||||
condition: { field: 'operation', value: 'list_users' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: [
|
||||
'hex_cancel_run',
|
||||
'hex_create_collection',
|
||||
'hex_get_collection',
|
||||
'hex_get_data_connection',
|
||||
'hex_get_group',
|
||||
'hex_get_project',
|
||||
'hex_get_project_runs',
|
||||
'hex_get_queried_tables',
|
||||
'hex_get_run_status',
|
||||
'hex_list_collections',
|
||||
'hex_list_data_connections',
|
||||
'hex_list_groups',
|
||||
'hex_list_projects',
|
||||
'hex_list_users',
|
||||
'hex_run_project',
|
||||
'hex_update_project',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'run_project':
|
||||
return 'hex_run_project'
|
||||
case 'get_run_status':
|
||||
return 'hex_get_run_status'
|
||||
case 'get_project_runs':
|
||||
return 'hex_get_project_runs'
|
||||
case 'cancel_run':
|
||||
return 'hex_cancel_run'
|
||||
case 'list_projects':
|
||||
return 'hex_list_projects'
|
||||
case 'get_project':
|
||||
return 'hex_get_project'
|
||||
case 'update_project':
|
||||
return 'hex_update_project'
|
||||
case 'get_queried_tables':
|
||||
return 'hex_get_queried_tables'
|
||||
case 'list_users':
|
||||
return 'hex_list_users'
|
||||
case 'list_groups':
|
||||
return 'hex_list_groups'
|
||||
case 'get_group':
|
||||
return 'hex_get_group'
|
||||
case 'list_collections':
|
||||
return 'hex_list_collections'
|
||||
case 'get_collection':
|
||||
return 'hex_get_collection'
|
||||
case 'create_collection':
|
||||
return 'hex_create_collection'
|
||||
case 'list_data_connections':
|
||||
return 'hex_list_data_connections'
|
||||
case 'get_data_connection':
|
||||
return 'hex_get_data_connection'
|
||||
default:
|
||||
return 'hex_run_project'
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
const op = params.operation
|
||||
|
||||
if (params.limit) result.limit = Number(params.limit)
|
||||
if (op === 'get_project_runs' && params.offset) result.offset = Number(params.offset)
|
||||
if (op === 'update_project' && params.projectStatus) result.status = params.projectStatus
|
||||
if (op === 'get_project_runs' && params.runStatusFilter)
|
||||
result.statusFilter = params.runStatusFilter
|
||||
if (op === 'get_group' && params.groupIdInput) result.groupId = params.groupIdInput
|
||||
if (op === 'list_users' && params.groupId) result.groupId = params.groupId
|
||||
if (op === 'create_collection' && params.collectionName) result.name = params.collectionName
|
||||
if (op === 'create_collection' && params.collectionDescription)
|
||||
result.description = params.collectionDescription
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
apiKey: { type: 'string', description: 'Hex API token' },
|
||||
projectId: { type: 'string', description: 'Project UUID' },
|
||||
runId: { type: 'string', description: 'Run UUID' },
|
||||
inputParams: { type: 'json', description: 'Input parameters for project run' },
|
||||
dryRun: { type: 'boolean', description: 'Perform a dry run without executing the project' },
|
||||
updateCache: {
|
||||
type: 'boolean',
|
||||
description: '(Deprecated) Update cached results after execution',
|
||||
},
|
||||
updatePublishedResults: {
|
||||
type: 'boolean',
|
||||
description: 'Update published app results after execution',
|
||||
},
|
||||
useCachedSqlResults: {
|
||||
type: 'boolean',
|
||||
description: 'Use cached SQL results instead of re-running queries',
|
||||
},
|
||||
projectStatus: {
|
||||
type: 'string',
|
||||
description: 'New project status name (custom workspace status label)',
|
||||
},
|
||||
limit: { type: 'number', description: 'Max number of results to return' },
|
||||
offset: { type: 'number', description: 'Offset for paginated results' },
|
||||
includeArchived: { type: 'boolean', description: 'Include archived projects' },
|
||||
statusFilter: { type: 'string', description: 'Filter projects by status' },
|
||||
runStatusFilter: { type: 'string', description: 'Filter runs by status' },
|
||||
groupId: { type: 'string', description: 'Filter users by group UUID' },
|
||||
groupIdInput: { type: 'string', description: 'Group UUID for get group' },
|
||||
collectionId: { type: 'string', description: 'Collection UUID' },
|
||||
collectionName: { type: 'string', description: 'Collection name' },
|
||||
collectionDescription: { type: 'string', description: 'Collection description' },
|
||||
dataConnectionId: { type: 'string', description: 'Data connection UUID' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
// Run creation outputs
|
||||
projectId: { type: 'string', description: 'Project UUID' },
|
||||
runId: { type: 'string', description: 'Run UUID' },
|
||||
runUrl: { type: 'string', description: 'URL to view the run' },
|
||||
runStatusUrl: { type: 'string', description: 'URL to check run status' },
|
||||
projectVersion: { type: 'number', description: 'Project version number' },
|
||||
// Run status outputs
|
||||
status: {
|
||||
type: 'json',
|
||||
description: 'Project status object ({ name }) or run status string',
|
||||
},
|
||||
startTime: { type: 'string', description: 'Run start time' },
|
||||
endTime: { type: 'string', description: 'Run end time' },
|
||||
elapsedTime: { type: 'number', description: 'Elapsed time in seconds' },
|
||||
traceId: { type: 'string', description: 'Trace ID for debugging' },
|
||||
// Project outputs
|
||||
id: { type: 'string', description: 'Resource ID' },
|
||||
title: { type: 'string', description: 'Project title' },
|
||||
name: { type: 'string', description: 'Resource name' },
|
||||
description: { type: 'string', description: 'Resource description' },
|
||||
type: { type: 'string', description: 'Project type (PROJECT or COMPONENT)' },
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp' },
|
||||
lastEditedAt: { type: 'string', description: 'Last edited timestamp' },
|
||||
lastPublishedAt: { type: 'string', description: 'Last published timestamp' },
|
||||
archivedAt: { type: 'string', description: 'Archived timestamp' },
|
||||
trashedAt: { type: 'string', description: 'Trashed timestamp' },
|
||||
// List outputs
|
||||
projects: {
|
||||
type: 'json',
|
||||
description: 'List of projects with id, title, status, type, creator, owner, createdAt',
|
||||
},
|
||||
runs: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of runs with runId, status, runUrl, startTime, endTime, elapsedTime, projectVersion',
|
||||
},
|
||||
users: { type: 'json', description: 'List of users with id, name, email, role' },
|
||||
groups: { type: 'json', description: 'List of groups with id, name, createdAt' },
|
||||
collections: {
|
||||
type: 'json',
|
||||
description: 'List of collections with id, name, description, creator',
|
||||
},
|
||||
connections: {
|
||||
type: 'json',
|
||||
description:
|
||||
'List of data connections with id, name, type, description, connectViaSsh, includeMagic, allowWritebackCells',
|
||||
},
|
||||
tables: {
|
||||
type: 'json',
|
||||
description: 'List of queried tables with dataConnectionId, dataConnectionName, tableName',
|
||||
},
|
||||
categories: {
|
||||
type: 'json',
|
||||
description: 'Project categories with name and description',
|
||||
},
|
||||
creator: { type: 'json', description: 'Creator details ({ email, id })' },
|
||||
owner: { type: 'json', description: 'Owner details ({ email })' },
|
||||
total: { type: 'number', description: 'Total results returned' },
|
||||
// Cancel output
|
||||
success: { type: 'boolean', description: 'Whether the operation succeeded' },
|
||||
// Data connection flags
|
||||
connectViaSsh: { type: 'boolean', description: 'SSH tunneling enabled' },
|
||||
includeMagic: { type: 'boolean', description: 'Magic AI features enabled' },
|
||||
allowWritebackCells: { type: 'boolean', description: 'Writeback cells allowed' },
|
||||
},
|
||||
}
|
||||
@@ -9,10 +9,10 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
description:
|
||||
'Send, update, delete messages, add reactions in Slack or trigger workflows from Slack events',
|
||||
'Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events',
|
||||
authMode: AuthMode.OAuth,
|
||||
longDescription:
|
||||
'Integrate Slack into the workflow. Can send, update, and delete messages, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
|
||||
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
|
||||
docsLink: 'https://docs.sim.ai/tools/slack',
|
||||
category: 'tools',
|
||||
bgColor: '#611f69',
|
||||
@@ -25,6 +25,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Send Message', id: 'send' },
|
||||
{ label: 'Send Ephemeral Message', id: 'ephemeral' },
|
||||
{ label: 'Create Canvas', id: 'canvas' },
|
||||
{ label: 'Read Messages', id: 'read' },
|
||||
{ label: 'Get Message', id: 'get_message' },
|
||||
@@ -116,15 +117,21 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
placeholder: 'Select Slack channel',
|
||||
mode: 'basic',
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
not: true,
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
value: 'dm',
|
||||
condition: (values?: Record<string, unknown>) => {
|
||||
const op = values?.operation as string
|
||||
if (op === 'ephemeral') {
|
||||
return { field: 'operation', value: 'ephemeral' }
|
||||
}
|
||||
return {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
not: true,
|
||||
},
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
value: 'dm',
|
||||
not: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
@@ -135,15 +142,21 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
canonicalParamId: 'channel',
|
||||
placeholder: 'Enter Slack channel ID (e.g., C1234567890)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
not: true,
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
value: 'dm',
|
||||
condition: (values?: Record<string, unknown>) => {
|
||||
const op = values?.operation as string
|
||||
if (op === 'ephemeral') {
|
||||
return { field: 'operation', value: 'ephemeral' }
|
||||
}
|
||||
return {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
not: true,
|
||||
},
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
value: 'dm',
|
||||
not: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
@@ -175,6 +188,31 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'ephemeralUser',
|
||||
title: 'Target User',
|
||||
type: 'short-input',
|
||||
placeholder: 'User ID who will see the message (e.g., U1234567890)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'ephemeral',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'messageFormat',
|
||||
title: 'Message Format',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Plain Text', id: 'text' },
|
||||
{ label: 'Block Kit', id: 'blocks' },
|
||||
],
|
||||
value: () => 'text',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['send', 'ephemeral', 'update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Message',
|
||||
@@ -182,9 +220,77 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
placeholder: 'Enter your message (supports Slack mrkdwn)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'send',
|
||||
value: ['send', 'ephemeral'],
|
||||
and: { field: 'messageFormat', value: 'blocks', not: true },
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['send', 'ephemeral'],
|
||||
and: { field: 'messageFormat', value: 'blocks', not: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blocks',
|
||||
title: 'Block Kit Blocks',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: 'JSON array of Block Kit blocks',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['send', 'ephemeral', 'update'],
|
||||
and: { field: 'messageFormat', value: 'blocks' },
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['send', 'ephemeral', 'update'],
|
||||
and: { field: 'messageFormat', value: 'blocks' },
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert at Slack Block Kit.
|
||||
Generate ONLY a valid JSON array of Block Kit blocks based on the user's request.
|
||||
The output MUST be a JSON array starting with [ and ending with ].
|
||||
|
||||
Current blocks: {context}
|
||||
|
||||
Available block types for messages:
|
||||
- "section": Displays text with an optional accessory element. Text uses { "type": "mrkdwn", "text": "..." } or { "type": "plain_text", "text": "..." }.
|
||||
- "header": Large text header. Text must be plain_text.
|
||||
- "divider": A horizontal rule separator. No fields needed besides type.
|
||||
- "image": Displays an image. Requires "image_url" and "alt_text".
|
||||
- "context": Contextual info with an "elements" array of image and text objects.
|
||||
- "actions": Interactive elements like buttons. Each button needs "type": "button", a "text" object, and an "action_id".
|
||||
- "rich_text": Structured rich text with "elements" array of rich_text_section objects.
|
||||
|
||||
Example output:
|
||||
[
|
||||
{
|
||||
"type": "header",
|
||||
"text": { "type": "plain_text", "text": "Order Confirmation" }
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Your order *#1234* has been confirmed." }
|
||||
},
|
||||
{ "type": "divider" },
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": { "type": "plain_text", "text": "View Order" },
|
||||
"action_id": "view_order",
|
||||
"url": "https://example.com/orders/1234"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
You can reference workflow variables using angle brackets, e.g., <blockName.output>.
|
||||
Do not include any explanations, markdown formatting, or other text outside the JSON array.`,
|
||||
placeholder: 'Describe the Block Kit layout you want to create...',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'threadTs',
|
||||
@@ -193,7 +299,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
placeholder: 'Reply to thread (e.g., 1405894322.002768)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'send',
|
||||
value: ['send', 'ephemeral'],
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
@@ -456,8 +562,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'update',
|
||||
and: { field: 'messageFormat', value: 'blocks', not: true },
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'update',
|
||||
and: { field: 'messageFormat', value: 'blocks', not: true },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Delete Message specific fields
|
||||
{
|
||||
@@ -499,6 +610,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
tools: {
|
||||
access: [
|
||||
'slack_message',
|
||||
'slack_ephemeral_message',
|
||||
'slack_canvas',
|
||||
'slack_message_reader',
|
||||
'slack_get_message',
|
||||
@@ -517,6 +629,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
switch (params.operation) {
|
||||
case 'send':
|
||||
return 'slack_message'
|
||||
case 'ephemeral':
|
||||
return 'slack_ephemeral_message'
|
||||
case 'canvas':
|
||||
return 'slack_canvas'
|
||||
case 'read':
|
||||
@@ -554,13 +668,16 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
destinationType,
|
||||
channel,
|
||||
dmUserId,
|
||||
messageFormat,
|
||||
text,
|
||||
title,
|
||||
content,
|
||||
limit,
|
||||
oldest,
|
||||
files,
|
||||
blocks,
|
||||
threadTs,
|
||||
ephemeralUser,
|
||||
updateTimestamp,
|
||||
updateText,
|
||||
deleteTimestamp,
|
||||
@@ -602,10 +719,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
|
||||
switch (operation) {
|
||||
case 'send': {
|
||||
baseParams.text = text
|
||||
baseParams.text = messageFormat === 'blocks' && !text ? ' ' : text
|
||||
if (threadTs) {
|
||||
baseParams.threadTs = threadTs
|
||||
}
|
||||
if (blocks) {
|
||||
baseParams.blocks = blocks
|
||||
}
|
||||
// files is the canonical param from attachmentFiles (basic) or files (advanced)
|
||||
const normalizedFiles = normalizeFileInput(files)
|
||||
if (normalizedFiles) {
|
||||
@@ -614,6 +734,18 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
break
|
||||
}
|
||||
|
||||
case 'ephemeral': {
|
||||
baseParams.text = messageFormat === 'blocks' && !text ? ' ' : text
|
||||
baseParams.user = ephemeralUser ? String(ephemeralUser).trim() : ''
|
||||
if (threadTs) {
|
||||
baseParams.threadTs = threadTs
|
||||
}
|
||||
if (blocks) {
|
||||
baseParams.blocks = blocks
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'canvas':
|
||||
baseParams.title = title
|
||||
baseParams.content = content
|
||||
@@ -680,7 +812,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
|
||||
case 'update':
|
||||
baseParams.timestamp = updateTimestamp
|
||||
baseParams.text = updateText
|
||||
baseParams.text = messageFormat === 'blocks' && !updateText ? ' ' : updateText
|
||||
if (blocks) {
|
||||
baseParams.blocks = blocks
|
||||
}
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
@@ -699,6 +834,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
messageFormat: { type: 'string', description: 'Message format: text or blocks' },
|
||||
authMethod: { type: 'string', description: 'Authentication method' },
|
||||
destinationType: { type: 'string', description: 'Destination type (channel or dm)' },
|
||||
credential: { type: 'string', description: 'Slack access token' },
|
||||
@@ -731,6 +867,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
// List Users inputs
|
||||
includeDeleted: { type: 'string', description: 'Include deactivated users (true/false)' },
|
||||
userLimit: { type: 'string', description: 'Maximum number of users to return' },
|
||||
// Ephemeral message inputs
|
||||
ephemeralUser: { type: 'string', description: 'User ID who will see the ephemeral message' },
|
||||
blocks: { type: 'json', description: 'Block Kit layout blocks as a JSON array' },
|
||||
// Get User inputs
|
||||
userId: { type: 'string', description: 'User ID to look up' },
|
||||
// Get Message inputs
|
||||
@@ -758,6 +897,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
},
|
||||
files: { type: 'file[]', description: 'Files attached to the message' },
|
||||
|
||||
// slack_ephemeral_message outputs (ephemeral operation)
|
||||
messageTs: {
|
||||
type: 'string',
|
||||
description: 'Timestamp of the ephemeral message (cannot be used to update or delete)',
|
||||
},
|
||||
|
||||
// slack_canvas outputs
|
||||
canvas_id: { type: 'string', description: 'Canvas identifier for created canvases' },
|
||||
title: { type: 'string', description: 'Canvas title' },
|
||||
|
||||
@@ -55,6 +55,7 @@ import { GrafanaBlock } from '@/blocks/blocks/grafana'
|
||||
import { GrainBlock } from '@/blocks/blocks/grain'
|
||||
import { GreptileBlock } from '@/blocks/blocks/greptile'
|
||||
import { GuardrailsBlock } from '@/blocks/blocks/guardrails'
|
||||
import { HexBlock } from '@/blocks/blocks/hex'
|
||||
import { HubSpotBlock } from '@/blocks/blocks/hubspot'
|
||||
import { HuggingFaceBlock } from '@/blocks/blocks/huggingface'
|
||||
import { HumanInTheLoopBlock } from '@/blocks/blocks/human_in_the_loop'
|
||||
@@ -240,6 +241,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
grain: GrainBlock,
|
||||
greptile: GreptileBlock,
|
||||
guardrails: GuardrailsBlock,
|
||||
hex: HexBlock,
|
||||
hubspot: HubSpotBlock,
|
||||
huggingface: HuggingFaceBlock,
|
||||
human_in_the_loop: HumanInTheLoopBlock,
|
||||
|
||||
@@ -15,10 +15,12 @@ export function ChevronDown(props: SVGProps<SVGSVGElement>) {
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M5.47124 5.47133C5.34622 5.59631 5.17668 5.66652 4.9999 5.66652C4.82313 5.66652 4.65359 5.59631 4.52857 5.47133L0.757237 1.69999C0.693563 1.6385 0.642775 1.56493 0.607836 1.4836C0.572896 1.40226 0.554505 1.31478 0.553736 1.22626C0.552967 1.13774 0.569835 1.04996 0.603355 0.968026C0.636876 0.886095 0.686378 0.81166 0.748973 0.749064C0.811568 0.686469 0.886003 0.636967 0.967934 0.603447C1.04986 0.569926 1.13765 0.553058 1.22617 0.553828C1.31469 0.554597 1.40217 0.572988 1.48351 0.607927C1.56484 0.642866 1.63841 0.693654 1.6999 0.757328L4.9999 4.05733L8.2999 0.757328C8.42564 0.635889 8.59404 0.568693 8.76884 0.570212C8.94363 0.571731 9.11084 0.641843 9.23445 0.765449C9.35805 0.889054 9.42817 1.05626 9.42969 1.23106C9.43121 1.40586 9.36401 1.57426 9.24257 1.69999L5.47124 5.47133Z'
|
||||
fill='currentColor'
|
||||
d='M1 1L5 5L9 1'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -537,6 +537,34 @@ export function GithubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function GithubOutlineIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M15 21C15 21 15 18.73 15 18C15 17.37 15.15 16.04 14.5 15.5C15.89 15.37 16.98 14.92 18 14C19.02 13.08 19.5 11.69 19.5 9.5C19.5 8 19.25 7 18.5 6C18.79 5.22 18.84 4 18.5 3C16.94 3 15.53 4.07 15 4.5C14.61 4.4 13.67 4 12 4C10.33 4 9.39 4.4 9 4.5C8.47 4.07 7.06 3 5.5 3C5.16 4 5.21 5.22 5.5 6C4.75 7 4.5 8 4.5 9.5C4.5 11.69 4.98 13.08 6 14C7.02 14.92 8.11 15.37 9.5 15.5C8.85 16.04 9 17.37 9 18C9 18.73 9 21 9 21'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M9 19C7.59 19 6.16 18.44 5.31 17.81C4.47 17.18 4.22 16.15 3 15.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
|
||||
@@ -5819,3 +5847,15 @@ export function RedisIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HexIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1450.3 600'>
|
||||
<path
|
||||
fill='#5F509D'
|
||||
fillRule='evenodd'
|
||||
d='m250.11,0v199.49h-50V0H0v600h200.11v-300.69h50v300.69h200.18V0h-200.18Zm249.9,0v600h450.29v-250.23h-200.2v149h-50v-199.46h250.2V0h-450.29Zm200.09,199.49v-99.49h50v99.49h-50Zm550.02,0V0h200.18v150l-100,100.09,100,100.09v249.82h-200.18v-300.69h-50v300.69h-200.11v-249.82l100.11-100.09-100.11-100.09V0h200.11v199.49h50Z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
|
||||
const brand = getBrandConfig()
|
||||
|
||||
const defaultTitle = brand.name
|
||||
const summaryFull = `Sim is an open-source AI agent workflow builder. Developers at trail-blazing startups to Fortune 500 companies deploy agentic workflows on the Sim platform. 60,000+ developers already use Sim to build and deploy AI agent workflows and connect them to 100+ apps. Sim is SOC2 and HIPAA compliant, ensuring enterprise-grade security for AI automation.`
|
||||
const summaryShort = `Sim is an open-source AI agent workflow builder for production workflows.`
|
||||
const summaryFull = `Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders — from startups to Fortune 500 companies. SOC2 and HIPAA compliant.`
|
||||
const summaryShort = `Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.`
|
||||
|
||||
return {
|
||||
title: {
|
||||
@@ -22,20 +22,21 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
|
||||
authors: [{ name: brand.name }],
|
||||
generator: 'Next.js',
|
||||
keywords: [
|
||||
'AI agent',
|
||||
'AI agent builder',
|
||||
'AI agent workflow',
|
||||
'AI workflow automation',
|
||||
'visual workflow editor',
|
||||
'AI agents',
|
||||
'workflow canvas',
|
||||
'agentic workforce',
|
||||
'AI agent platform',
|
||||
'open-source AI agents',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI integrations',
|
||||
'knowledge base',
|
||||
'AI automation',
|
||||
'workflow builder',
|
||||
'AI workflow orchestration',
|
||||
'enterprise AI',
|
||||
'AI agent deployment',
|
||||
'intelligent automation',
|
||||
'AI tools',
|
||||
'workflow designer',
|
||||
'artificial intelligence',
|
||||
'business automation',
|
||||
'AI agent workflows',
|
||||
'visual programming',
|
||||
],
|
||||
referrer: 'origin-when-cross-origin',
|
||||
creator: brand.name,
|
||||
@@ -130,11 +131,11 @@ export function generateStructuredData() {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Sim',
|
||||
description:
|
||||
'Sim is an open-source AI agent workflow builder. Developers at trail-blazing startups to Fortune 500 companies deploy agentic workflows on the Sim platform. 60,000+ developers already use Sim to build and deploy AI agent workflows and connect them to 100+ apps. Sim is SOC2 and HIPAA compliant, ensuring enterprise-level security.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
url: getBaseUrl(),
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
applicationSubCategory: 'AIWorkflowAutomation',
|
||||
operatingSystem: 'Web',
|
||||
applicationSubCategory: 'AIAgentPlatform',
|
||||
areaServed: 'Worldwide',
|
||||
availableLanguage: ['en'],
|
||||
offers: {
|
||||
@@ -147,10 +148,13 @@ export function generateStructuredData() {
|
||||
url: 'https://sim.ai',
|
||||
},
|
||||
featureList: [
|
||||
'Visual AI Agent Builder',
|
||||
'Workflow Canvas Interface',
|
||||
'AI Agent Automation',
|
||||
'Custom AI Workflows',
|
||||
'AI Agent Creation',
|
||||
'Agentic Workflow Orchestration',
|
||||
'1,000+ Integrations',
|
||||
'LLM Orchestration',
|
||||
'Knowledge Base Creation',
|
||||
'Table Creation',
|
||||
'Document Creation',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type QueryClient,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
@@ -159,27 +158,13 @@ interface UseLogDetailOptions {
|
||||
}
|
||||
|
||||
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: logKeys.detail(logId),
|
||||
queryFn: () => fetchLogDetail(logId as string),
|
||||
enabled: Boolean(logId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: 30 * 1000,
|
||||
initialData: () => {
|
||||
if (!logId) return undefined
|
||||
const listQueries = queryClient.getQueriesData<{
|
||||
pages: { logs: WorkflowLog[] }[]
|
||||
}>({
|
||||
queryKey: logKeys.lists(),
|
||||
})
|
||||
for (const [, data] of listQueries) {
|
||||
const match = data?.pages?.flatMap((p) => p.logs).find((l) => l.id === logId)
|
||||
if (match) return match
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
initialDataUpdatedAt: 0,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
customSession,
|
||||
emailOTP,
|
||||
genericOAuth,
|
||||
jwt,
|
||||
oidcProvider,
|
||||
oneTimeToken,
|
||||
organization,
|
||||
} from 'better-auth/plugins'
|
||||
@@ -23,6 +25,12 @@ import {
|
||||
renderPasswordResetEmail,
|
||||
renderWelcomeEmail,
|
||||
} from '@/components/emails'
|
||||
import {
|
||||
evictCachedMetadata,
|
||||
isMetadataUrl,
|
||||
resolveClientMetadata,
|
||||
upsertCimdClient,
|
||||
} from '@/lib/auth/cimd'
|
||||
import { sendPlanWelcomeEmail } from '@/lib/billing'
|
||||
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
|
||||
import { handleNewUser } from '@/lib/billing/core/usage'
|
||||
@@ -80,6 +88,8 @@ export const auth = betterAuth({
|
||||
trustedOrigins: [
|
||||
getBaseUrl(),
|
||||
...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []),
|
||||
'https://claude.ai',
|
||||
'https://claude.com',
|
||||
].filter(Boolean),
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
@@ -537,11 +547,51 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
|
||||
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
|
||||
if (clientId && isMetadataUrl(clientId)) {
|
||||
try {
|
||||
const { metadata, fromCache } = await resolveClientMetadata(clientId)
|
||||
if (!fromCache) {
|
||||
try {
|
||||
await upsertCimdClient(metadata)
|
||||
} catch (upsertErr) {
|
||||
evictCachedMetadata(clientId)
|
||||
throw upsertErr
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('CIMD resolution failed', {
|
||||
clientId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}),
|
||||
},
|
||||
plugins: [
|
||||
nextCookies(),
|
||||
jwt({
|
||||
jwks: {
|
||||
keyPairConfig: { alg: 'RS256' },
|
||||
},
|
||||
disableSettingJwtHeader: true,
|
||||
}),
|
||||
oidcProvider({
|
||||
loginPage: '/login',
|
||||
consentPage: '/oauth/consent',
|
||||
requirePKCE: true,
|
||||
allowPlainCodeChallengeMethod: false,
|
||||
allowDynamicClientRegistration: true,
|
||||
useJWTPlugin: true,
|
||||
scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
|
||||
metadata: {
|
||||
client_id_metadata_document_supported: true,
|
||||
} as Record<string, unknown>,
|
||||
}),
|
||||
oneTimeToken({
|
||||
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
|
||||
}),
|
||||
|
||||
168
apps/sim/lib/auth/cimd.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { oauthApplication } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const logger = createLogger('cimd')
|
||||
|
||||
interface ClientMetadataDocument {
|
||||
client_id: string
|
||||
client_name: string
|
||||
logo_uri?: string
|
||||
redirect_uris: string[]
|
||||
client_uri?: string
|
||||
policy_uri?: string
|
||||
tos_uri?: string
|
||||
contacts?: string[]
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export function isMetadataUrl(clientId: string): boolean {
|
||||
return clientId.startsWith('https://')
|
||||
}
|
||||
|
||||
async function fetchClientMetadata(url: string): Promise<ClientMetadataDocument> {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error('CIMD URL must use HTTPS')
|
||||
}
|
||||
|
||||
const res = await secureFetchWithValidation(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
timeout: 5000,
|
||||
maxResponseBytes: 256 * 1024,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`CIMD fetch failed: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const doc = (await res.json()) as ClientMetadataDocument
|
||||
|
||||
if (doc.client_id !== url) {
|
||||
throw new Error(`CIMD client_id mismatch: document has "${doc.client_id}", expected "${url}"`)
|
||||
}
|
||||
|
||||
if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) {
|
||||
throw new Error('CIMD document must contain at least one redirect_uri')
|
||||
}
|
||||
|
||||
for (const uri of doc.redirect_uris) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(uri)
|
||||
} catch {
|
||||
throw new Error(`Invalid redirect_uri: ${uri}`)
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error(`Invalid redirect_uri scheme: ${parsed.protocol}`)
|
||||
}
|
||||
if (uri.includes(',')) {
|
||||
throw new Error(`redirect_uri must not contain commas: ${uri}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.logo_uri) {
|
||||
try {
|
||||
const logoParsed = new URL(doc.logo_uri)
|
||||
if (logoParsed.protocol !== 'https:') {
|
||||
doc.logo_uri = undefined
|
||||
}
|
||||
} catch {
|
||||
doc.logo_uri = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!doc.client_name || typeof doc.client_name !== 'string') {
|
||||
throw new Error('CIMD document must contain a client_name')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const NEGATIVE_CACHE_TTL_MS = 60 * 1000
|
||||
const cache = new Map<string, { doc: ClientMetadataDocument; expiresAt: number }>()
|
||||
const failureCache = new Map<string, { error: string; expiresAt: number }>()
|
||||
const inflight = new Map<string, Promise<ClientMetadataDocument>>()
|
||||
|
||||
interface ResolveResult {
|
||||
metadata: ClientMetadataDocument
|
||||
fromCache: boolean
|
||||
}
|
||||
|
||||
export async function resolveClientMetadata(url: string): Promise<ResolveResult> {
|
||||
const cached = cache.get(url)
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { metadata: cached.doc, fromCache: true }
|
||||
}
|
||||
|
||||
const failed = failureCache.get(url)
|
||||
if (failed && Date.now() < failed.expiresAt) {
|
||||
throw new Error(failed.error)
|
||||
}
|
||||
|
||||
const pending = inflight.get(url)
|
||||
if (pending) {
|
||||
return pending.then((doc) => ({ metadata: doc, fromCache: false }))
|
||||
}
|
||||
|
||||
const promise = fetchClientMetadata(url)
|
||||
.then((doc) => {
|
||||
cache.set(url, { doc, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
failureCache.delete(url)
|
||||
return doc
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
failureCache.set(url, { error: message, expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS })
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(url)
|
||||
})
|
||||
|
||||
inflight.set(url, promise)
|
||||
return promise.then((doc) => ({ metadata: doc, fromCache: false }))
|
||||
}
|
||||
|
||||
export function evictCachedMetadata(url: string): void {
|
||||
cache.delete(url)
|
||||
}
|
||||
|
||||
export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise<void> {
|
||||
const now = new Date()
|
||||
const redirectURLs = metadata.redirect_uris.join(',')
|
||||
|
||||
await db
|
||||
.insert(oauthApplication)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
clientId: metadata.client_id,
|
||||
name: metadata.client_name,
|
||||
icon: metadata.logo_uri ?? null,
|
||||
redirectURLs,
|
||||
type: 'public',
|
||||
clientSecret: null,
|
||||
userId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: oauthApplication.clientId,
|
||||
set: {
|
||||
name: metadata.client_name,
|
||||
icon: metadata.logo_uri ?? null,
|
||||
redirectURLs,
|
||||
type: 'public',
|
||||
clientSecret: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Upserted CIMD client', {
|
||||
clientId: metadata.client_id,
|
||||
name: metadata.client_name,
|
||||
})
|
||||
}
|
||||
51
apps/sim/lib/auth/oauth-token.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { db } from '@sim/db'
|
||||
import { oauthAccessToken } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
|
||||
const logger = createLogger('OAuthToken')
|
||||
|
||||
interface OAuthTokenValidationResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
scopes?: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an OAuth 2.1 access token by looking it up in the oauthAccessToken table.
|
||||
* Returns the associated userId and scopes if the token is valid and not expired.
|
||||
*/
|
||||
export async function validateOAuthAccessToken(token: string): Promise<OAuthTokenValidationResult> {
|
||||
try {
|
||||
const [record] = await db
|
||||
.select({
|
||||
userId: oauthAccessToken.userId,
|
||||
scopes: oauthAccessToken.scopes,
|
||||
accessTokenExpiresAt: oauthAccessToken.accessTokenExpiresAt,
|
||||
})
|
||||
.from(oauthAccessToken)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthAccessToken.accessToken, token),
|
||||
gt(oauthAccessToken.accessTokenExpiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
return { success: false, error: 'Invalid or expired OAuth access token' }
|
||||
}
|
||||
|
||||
if (!record.userId) {
|
||||
return { success: false, error: 'OAuth token has no associated user' }
|
||||
}
|
||||
|
||||
const scopes = record.scopes.split(' ').filter(Boolean)
|
||||
|
||||
return { success: true, userId: record.userId, scopes }
|
||||
} catch (error) {
|
||||
logger.error('OAuth access token validation failed', { error })
|
||||
return { success: false, error: 'Token validation error' }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
export type ToolAnnotations = {
|
||||
readOnlyHint?: boolean
|
||||
destructiveHint?: boolean
|
||||
idempotentHint?: boolean
|
||||
openWorldHint?: boolean
|
||||
}
|
||||
|
||||
export type DirectToolDef = {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
|
||||
toolId: string
|
||||
annotations?: ToolAnnotations
|
||||
}
|
||||
|
||||
export type SubagentToolDef = {
|
||||
@@ -10,6 +18,7 @@ export type SubagentToolDef = {
|
||||
description: string
|
||||
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
|
||||
agentId: string
|
||||
annotations?: ToolAnnotations
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +35,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'list_workflows',
|
||||
@@ -45,6 +55,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'list_folders',
|
||||
@@ -61,6 +72,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workspaceId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'get_workflow',
|
||||
@@ -77,6 +89,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'create_workflow',
|
||||
@@ -105,6 +118,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'create_folder',
|
||||
@@ -129,6 +143,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'rename_workflow',
|
||||
@@ -148,6 +163,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'name'],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: 'move_workflow',
|
||||
@@ -168,6 +184,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: 'move_folder',
|
||||
@@ -189,6 +206,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['folderId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_workflow',
|
||||
@@ -214,6 +232,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_workflow_until_block',
|
||||
@@ -243,6 +262,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'stopAfterBlockId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_from_block',
|
||||
@@ -276,6 +296,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'startBlockId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_block',
|
||||
@@ -309,6 +330,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'blockId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'get_deployed_workflow_state',
|
||||
@@ -325,6 +347,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'generate_api_key',
|
||||
@@ -346,6 +369,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -397,6 +421,7 @@ WORKFLOW:
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_discovery',
|
||||
@@ -422,6 +447,7 @@ DO NOT USE (use direct tools instead):
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_plan',
|
||||
@@ -456,6 +482,7 @@ IMPORTANT: Pass the returned plan EXACTLY to sim_edit - do not modify or summari
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_edit',
|
||||
@@ -491,6 +518,7 @@ After sim_edit completes, you can test immediately with sim_test, or deploy with
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_deploy',
|
||||
@@ -524,6 +552,7 @@ ALSO CAN:
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_test',
|
||||
@@ -547,6 +576,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_debug',
|
||||
@@ -562,6 +592,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['error', 'workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_auth',
|
||||
@@ -576,6 +607,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_knowledge',
|
||||
@@ -590,6 +622,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'sim_custom_tool',
|
||||
@@ -604,6 +637,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'sim_info',
|
||||
@@ -619,6 +653,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_workflow',
|
||||
@@ -634,6 +669,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'sim_research',
|
||||
@@ -648,6 +684,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_superagent',
|
||||
@@ -662,6 +699,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: true, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_platform',
|
||||
@@ -676,5 +714,6 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -135,12 +135,13 @@ interface OutputFieldSchema {
|
||||
function matchesOperation(condition: any, operation: string): boolean {
|
||||
if (!condition) return false
|
||||
|
||||
const cond = typeof condition === 'function' ? condition() : condition
|
||||
const cond = typeof condition === 'function' ? condition({ operation }) : condition
|
||||
if (!cond) return false
|
||||
|
||||
if (cond.field === 'operation' && !cond.not) {
|
||||
if (cond.field === 'operation') {
|
||||
const values = Array.isArray(cond.value) ? cond.value : [cond.value]
|
||||
return values.includes(operation)
|
||||
const included = values.includes(operation)
|
||||
return cond.not ? !included : included
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -173,18 +174,10 @@ function extractInputsFromSubBlocks(
|
||||
// 1. Have no condition (common parameters)
|
||||
// 2. Have a condition matching the operation
|
||||
if (operation) {
|
||||
const condition = typeof sb.condition === 'function' ? sb.condition() : sb.condition
|
||||
if (condition) {
|
||||
if (condition.field === 'operation' && !condition.not) {
|
||||
// This is an operation-specific field
|
||||
const values = Array.isArray(condition.value) ? condition.value : [condition.value]
|
||||
if (!values.includes(operation)) {
|
||||
continue // Skip if doesn't match our operation
|
||||
}
|
||||
} else if (!matchesOperation(condition, operation)) {
|
||||
// Other condition that doesn't match
|
||||
continue
|
||||
}
|
||||
const condition =
|
||||
typeof sb.condition === 'function' ? sb.condition({ operation }) : sb.condition
|
||||
if (condition && !matchesOperation(condition, operation)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Environment utility functions for consistent environment detection across the application
|
||||
*/
|
||||
import { env, getEnv, isFalsy, isTruthy } from './env'
|
||||
import { env, isFalsy, isTruthy } from './env'
|
||||
|
||||
/**
|
||||
* Is the application running in production mode
|
||||
@@ -21,9 +21,10 @@ export const isTest = env.NODE_ENV === 'test'
|
||||
/**
|
||||
* Is this the hosted version of the application
|
||||
*/
|
||||
export const isHosted =
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
// export const isHosted =
|
||||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
export const isHosted = true
|
||||
|
||||
/**
|
||||
* Is billing enforcement enabled
|
||||
|
||||
176
apps/sim/lib/core/config/redis.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { createEnvMock, createMockRedis, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockRedisInstance = createMockRedis()
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
vi.mock('@/lib/core/config/env', () => createEnvMock({ REDIS_URL: 'redis://localhost:6379' }))
|
||||
vi.mock('ioredis', () => ({
|
||||
default: vi.fn(() => mockRedisInstance),
|
||||
}))
|
||||
|
||||
describe('redis config', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('onRedisReconnect', () => {
|
||||
it('should register and invoke reconnect listeners', async () => {
|
||||
const { onRedisReconnect, getRedisClient } = await import('./redis')
|
||||
const listener = vi.fn()
|
||||
onRedisReconnect(listener)
|
||||
|
||||
getRedisClient()
|
||||
|
||||
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not invoke listeners when PINGs succeed', async () => {
|
||||
const { onRedisReconnect, getRedisClient } = await import('./redis')
|
||||
const listener = vi.fn()
|
||||
onRedisReconnect(listener)
|
||||
|
||||
getRedisClient()
|
||||
mockRedisInstance.ping.mockResolvedValue('PONG')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset failure count on successful PING', async () => {
|
||||
const { onRedisReconnect, getRedisClient } = await import('./redis')
|
||||
const listener = vi.fn()
|
||||
onRedisReconnect(listener)
|
||||
|
||||
getRedisClient()
|
||||
|
||||
// 2 failures then a success — should reset counter
|
||||
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
mockRedisInstance.ping.mockResolvedValueOnce('PONG')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
// 2 more failures — should NOT trigger reconnect (counter was reset)
|
||||
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call disconnect(true) after 3 consecutive PING failures', async () => {
|
||||
const { getRedisClient } = await import('./redis')
|
||||
getRedisClient()
|
||||
|
||||
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(mockRedisInstance.disconnect).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(mockRedisInstance.disconnect).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle listener errors gracefully without breaking health check', async () => {
|
||||
const { onRedisReconnect, getRedisClient } = await import('./redis')
|
||||
const badListener = vi.fn(() => {
|
||||
throw new Error('listener crashed')
|
||||
})
|
||||
const goodListener = vi.fn()
|
||||
onRedisReconnect(badListener)
|
||||
onRedisReconnect(goodListener)
|
||||
|
||||
getRedisClient()
|
||||
mockRedisInstance.ping.mockRejectedValue(new Error('timeout'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(badListener).toHaveBeenCalledTimes(1)
|
||||
expect(goodListener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeRedisConnection', () => {
|
||||
it('should clear the PING interval', async () => {
|
||||
const { getRedisClient, closeRedisConnection } = await import('./redis')
|
||||
getRedisClient()
|
||||
|
||||
mockRedisInstance.quit.mockResolvedValue('OK')
|
||||
await closeRedisConnection()
|
||||
|
||||
// After closing, PING failures should not trigger disconnect
|
||||
mockRedisInstance.ping.mockRejectedValue(new Error('timeout'))
|
||||
await vi.advanceTimersByTimeAsync(30_000 * 5)
|
||||
expect(mockRedisInstance.disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryStrategy', () => {
|
||||
async function captureRetryStrategy(): Promise<(times: number) => number> {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('@sim/logger', () => loggerMock)
|
||||
vi.doMock('@/lib/core/config/env', () =>
|
||||
createEnvMock({ REDIS_URL: 'redis://localhost:6379' })
|
||||
)
|
||||
|
||||
let capturedConfig: Record<string, unknown> = {}
|
||||
vi.doMock('ioredis', () => ({
|
||||
default: vi.fn((_url: string, config: Record<string, unknown>) => {
|
||||
capturedConfig = config
|
||||
return { ping: vi.fn(), on: vi.fn() }
|
||||
}),
|
||||
}))
|
||||
|
||||
const { getRedisClient } = await import('./redis')
|
||||
getRedisClient()
|
||||
|
||||
return capturedConfig.retryStrategy as (times: number) => number
|
||||
}
|
||||
|
||||
it('should use exponential backoff with jitter', async () => {
|
||||
const retryStrategy = await captureRetryStrategy()
|
||||
expect(retryStrategy).toBeDefined()
|
||||
|
||||
// Base for attempt 1: min(1000 * 2^0, 10000) = 1000, jitter up to 300
|
||||
const delay1 = retryStrategy(1)
|
||||
expect(delay1).toBeGreaterThanOrEqual(1000)
|
||||
expect(delay1).toBeLessThanOrEqual(1300)
|
||||
|
||||
// Base for attempt 3: min(1000 * 2^2, 10000) = 4000, jitter up to 1200
|
||||
const delay3 = retryStrategy(3)
|
||||
expect(delay3).toBeGreaterThanOrEqual(4000)
|
||||
expect(delay3).toBeLessThanOrEqual(5200)
|
||||
|
||||
// Base for attempt 5: min(1000 * 2^4, 10000) = 10000, jitter up to 3000
|
||||
const delay5 = retryStrategy(5)
|
||||
expect(delay5).toBeGreaterThanOrEqual(10000)
|
||||
expect(delay5).toBeLessThanOrEqual(13000)
|
||||
})
|
||||
|
||||
it('should cap at 30s for attempts beyond 10', async () => {
|
||||
const retryStrategy = await captureRetryStrategy()
|
||||
expect(retryStrategy(11)).toBe(30000)
|
||||
expect(retryStrategy(100)).toBe(30000)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,63 @@ const logger = createLogger('Redis')
|
||||
const redisUrl = env.REDIS_URL
|
||||
|
||||
let globalRedisClient: Redis | null = null
|
||||
let pingFailures = 0
|
||||
let pingInterval: NodeJS.Timeout | null = null
|
||||
let pingInFlight = false
|
||||
|
||||
const PING_INTERVAL_MS = 30_000
|
||||
const MAX_PING_FAILURES = 3
|
||||
|
||||
/** Callbacks invoked when the PING health check forces a reconnect. */
|
||||
const reconnectListeners: Array<() => void> = []
|
||||
|
||||
/**
|
||||
* Register a callback that fires when the PING health check forces a reconnect.
|
||||
* Useful for resetting cached adapters that hold a stale Redis reference.
|
||||
*/
|
||||
export function onRedisReconnect(cb: () => void): void {
|
||||
reconnectListeners.push(cb)
|
||||
}
|
||||
|
||||
function startPingHealthCheck(redis: Redis): void {
|
||||
if (pingInterval) return
|
||||
|
||||
pingInterval = setInterval(async () => {
|
||||
if (pingInFlight) return
|
||||
pingInFlight = true
|
||||
try {
|
||||
await redis.ping()
|
||||
pingFailures = 0
|
||||
} catch (error) {
|
||||
pingFailures++
|
||||
logger.warn('Redis PING failed', {
|
||||
consecutiveFailures: pingFailures,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
if (pingFailures >= MAX_PING_FAILURES) {
|
||||
logger.error('Redis PING failed 3 consecutive times — forcing reconnect', {
|
||||
consecutiveFailures: pingFailures,
|
||||
})
|
||||
pingFailures = 0
|
||||
for (const cb of reconnectListeners) {
|
||||
try {
|
||||
cb()
|
||||
} catch (cbError) {
|
||||
logger.error('Redis reconnect listener error', { error: cbError })
|
||||
}
|
||||
}
|
||||
try {
|
||||
redis.disconnect(true)
|
||||
} catch (disconnectError) {
|
||||
logger.error('Error during forced Redis disconnect', { error: disconnectError })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pingInFlight = false
|
||||
}
|
||||
}, PING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Redis client instance.
|
||||
@@ -35,8 +92,10 @@ export function getRedisClient(): Redis | null {
|
||||
logger.error(`Redis reconnection attempt ${times}`, { nextRetryMs: 30000 })
|
||||
return 30000
|
||||
}
|
||||
const delay = Math.min(times * 500, 5000)
|
||||
logger.warn(`Redis reconnecting`, { attempt: times, nextRetryMs: delay })
|
||||
const base = Math.min(1000 * 2 ** (times - 1), 10000)
|
||||
const jitter = Math.random() * base * 0.3
|
||||
const delay = Math.round(base + jitter)
|
||||
logger.warn('Redis reconnecting', { attempt: times, nextRetryMs: delay })
|
||||
return delay
|
||||
},
|
||||
|
||||
@@ -54,6 +113,8 @@ export function getRedisClient(): Redis | null {
|
||||
globalRedisClient.on('close', () => logger.warn('Redis connection closed'))
|
||||
globalRedisClient.on('end', () => logger.error('Redis connection ended'))
|
||||
|
||||
startPingHealthCheck(globalRedisClient)
|
||||
|
||||
return globalRedisClient
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize Redis client', { error })
|
||||
@@ -118,6 +179,11 @@ export async function releaseLock(lockKey: string, value: string): Promise<boole
|
||||
* Use for graceful shutdown.
|
||||
*/
|
||||
export async function closeRedisConnection(): Promise<void> {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval)
|
||||
pingInterval = null
|
||||
}
|
||||
|
||||
if (globalRedisClient) {
|
||||
try {
|
||||
await globalRedisClient.quit()
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('RateLimiter', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should deny on storage error (fail closed)', async () => {
|
||||
it('should allow on storage error (fail open)', async () => {
|
||||
mockAdapter.consumeTokens.mockRejectedValue(new Error('Storage error'))
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
@@ -182,8 +182,8 @@ describe('RateLimiter', () => {
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.remaining).toBe(0)
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(1)
|
||||
})
|
||||
|
||||
it('should work for all non-manual trigger types', async () => {
|
||||
|
||||
@@ -100,17 +100,16 @@ export class RateLimiter {
|
||||
retryAfterMs: result.retryAfterMs,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Rate limit storage error - failing closed (denying request)', {
|
||||
logger.error('Rate limit storage error - failing open (allowing request)', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
userId,
|
||||
triggerType,
|
||||
isAsync,
|
||||
})
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
allowed: true,
|
||||
remaining: 1,
|
||||
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
|
||||
retryAfterMs: RATE_LIMIT_WINDOW_MS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
129
apps/sim/lib/core/rate-limiter/storage/factory.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
const reconnectCallbacks: Array<() => void> = []
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => ({
|
||||
getRedisClient: vi.fn(() => null),
|
||||
onRedisReconnect: vi.fn((cb: () => void) => {
|
||||
reconnectCallbacks.push(cb)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/storage', () => ({
|
||||
getStorageMethod: vi.fn(() => 'db'),
|
||||
}))
|
||||
|
||||
vi.mock('./db-token-bucket', () => ({
|
||||
DbTokenBucket: vi.fn(() => ({ type: 'db' })),
|
||||
}))
|
||||
|
||||
vi.mock('./redis-token-bucket', () => ({
|
||||
RedisTokenBucket: vi.fn(() => ({ type: 'redis' })),
|
||||
}))
|
||||
|
||||
describe('rate limit storage factory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reconnectCallbacks.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should fall back to DbTokenBucket when Redis is configured but client unavailable', async () => {
|
||||
const { getStorageMethod } = await import('@/lib/core/storage')
|
||||
vi.mocked(getStorageMethod).mockReturnValue('redis')
|
||||
|
||||
const { getRedisClient } = await import('@/lib/core/config/redis')
|
||||
vi.mocked(getRedisClient).mockReturnValue(null)
|
||||
|
||||
const { createStorageAdapter, resetStorageAdapter } = await import('./factory')
|
||||
resetStorageAdapter()
|
||||
|
||||
const adapter = createStorageAdapter()
|
||||
expect(adapter).toEqual({ type: 'db' })
|
||||
})
|
||||
|
||||
it('should use RedisTokenBucket when Redis client is available', async () => {
|
||||
const { getStorageMethod } = await import('@/lib/core/storage')
|
||||
vi.mocked(getStorageMethod).mockReturnValue('redis')
|
||||
|
||||
const { getRedisClient } = await import('@/lib/core/config/redis')
|
||||
vi.mocked(getRedisClient).mockReturnValue({ ping: vi.fn() } as never)
|
||||
|
||||
const { createStorageAdapter, resetStorageAdapter } = await import('./factory')
|
||||
resetStorageAdapter()
|
||||
|
||||
const adapter = createStorageAdapter()
|
||||
expect(adapter).toEqual({ type: 'redis' })
|
||||
})
|
||||
|
||||
it('should use DbTokenBucket when storage method is db', async () => {
|
||||
const { getStorageMethod } = await import('@/lib/core/storage')
|
||||
vi.mocked(getStorageMethod).mockReturnValue('db')
|
||||
|
||||
const { createStorageAdapter, resetStorageAdapter } = await import('./factory')
|
||||
resetStorageAdapter()
|
||||
|
||||
const adapter = createStorageAdapter()
|
||||
expect(adapter).toEqual({ type: 'db' })
|
||||
})
|
||||
|
||||
it('should cache the adapter and return same instance', async () => {
|
||||
const { getStorageMethod } = await import('@/lib/core/storage')
|
||||
vi.mocked(getStorageMethod).mockReturnValue('db')
|
||||
|
||||
const { createStorageAdapter, resetStorageAdapter } = await import('./factory')
|
||||
resetStorageAdapter()
|
||||
|
||||
const adapter1 = createStorageAdapter()
|
||||
const adapter2 = createStorageAdapter()
|
||||
expect(adapter1).toBe(adapter2)
|
||||
})
|
||||
|
||||
it('should register a reconnect listener that resets cached adapter', async () => {
|
||||
const { getStorageMethod } = await import('@/lib/core/storage')
|
||||
vi.mocked(getStorageMethod).mockReturnValue('db')
|
||||
|
||||
const { createStorageAdapter, resetStorageAdapter } = await import('./factory')
|
||||
resetStorageAdapter()
|
||||
|
||||
const adapter1 = createStorageAdapter()
|
||||
|
||||
// Simulate Redis reconnect — should reset cached adapter
|
||||
expect(reconnectCallbacks.length).toBeGreaterThan(0)
|
||||
reconnectCallbacks[0]()
|
||||
|
||||
// Next call should create a fresh adapter
|
||||
const adapter2 = createStorageAdapter()
|
||||
expect(adapter2).not.toBe(adapter1)
|
||||
})
|
||||
|
||||
it('should re-evaluate storage on next call after reconnect resets cache', async () => {
|
||||
const { getStorageMethod } = await import('@/lib/core/storage')
|
||||
const { getRedisClient } = await import('@/lib/core/config/redis')
|
||||
|
||||
// Start with Redis unavailable — falls back to DB
|
||||
vi.mocked(getStorageMethod).mockReturnValue('redis')
|
||||
vi.mocked(getRedisClient).mockReturnValue(null)
|
||||
|
||||
const { createStorageAdapter, resetStorageAdapter } = await import('./factory')
|
||||
resetStorageAdapter()
|
||||
|
||||
const adapter1 = createStorageAdapter()
|
||||
expect(adapter1).toEqual({ type: 'db' })
|
||||
|
||||
// Simulate reconnect
|
||||
reconnectCallbacks[0]()
|
||||
|
||||
// Now Redis is available
|
||||
vi.mocked(getRedisClient).mockReturnValue({ ping: vi.fn() } as never)
|
||||
|
||||
const adapter2 = createStorageAdapter()
|
||||
expect(adapter2).toEqual({ type: 'redis' })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { getRedisClient, onRedisReconnect } from '@/lib/core/config/redis'
|
||||
import { getStorageMethod, type StorageMethod } from '@/lib/core/storage'
|
||||
import type { RateLimitStorageAdapter } from './adapter'
|
||||
import { DbTokenBucket } from './db-token-bucket'
|
||||
@@ -8,21 +8,33 @@ import { RedisTokenBucket } from './redis-token-bucket'
|
||||
const logger = createLogger('RateLimitStorage')
|
||||
|
||||
let cachedAdapter: RateLimitStorageAdapter | null = null
|
||||
let reconnectListenerRegistered = false
|
||||
|
||||
export function createStorageAdapter(): RateLimitStorageAdapter {
|
||||
if (cachedAdapter) {
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
if (!reconnectListenerRegistered) {
|
||||
onRedisReconnect(() => {
|
||||
cachedAdapter = null
|
||||
})
|
||||
reconnectListenerRegistered = true
|
||||
}
|
||||
|
||||
const storageMethod = getStorageMethod()
|
||||
|
||||
if (storageMethod === 'redis') {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
throw new Error('Redis configured but client unavailable')
|
||||
logger.warn(
|
||||
'Redis configured but client unavailable - falling back to PostgreSQL for rate limiting'
|
||||
)
|
||||
cachedAdapter = new DbTokenBucket()
|
||||
} else {
|
||||
logger.info('Rate limiting: Using Redis')
|
||||
cachedAdapter = new RedisTokenBucket(redis)
|
||||
}
|
||||
logger.info('Rate limiting: Using Redis')
|
||||
cachedAdapter = new RedisTokenBucket(redis)
|
||||
} else {
|
||||
logger.info('Rate limiting: Using PostgreSQL')
|
||||
cachedAdapter = new DbTokenBucket()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createEnvMock, loggerMock } from '@sim/testing'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockProc = EventEmitter & {
|
||||
@@ -130,13 +131,7 @@ async function loadExecutionModule(options: {
|
||||
return next() as any
|
||||
})
|
||||
|
||||
vi.doMock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.doMock('@sim/logger', () => loggerMock)
|
||||
|
||||
const secureFetchMock = vi.fn(
|
||||
options.secureFetchImpl ??
|
||||
@@ -154,8 +149,12 @@ async function loadExecutionModule(options: {
|
||||
secureFetchWithValidation: secureFetchMock,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/utils/logging', () => ({
|
||||
sanitizeUrlForLog: vi.fn((url: string) => url),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () =>
|
||||
createEnvMock({
|
||||
IVM_POOL_SIZE: '1',
|
||||
IVM_MAX_CONCURRENT: '100',
|
||||
IVM_MAX_PER_WORKER: '100',
|
||||
@@ -168,8 +167,8 @@ async function loadExecutionModule(options: {
|
||||
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: '1000',
|
||||
IVM_QUEUE_TIMEOUT_MS: '1000',
|
||||
...(options.envOverrides ?? {}),
|
||||
},
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
const redisEval = options.redisEvalImpl ? vi.fn(options.redisEvalImpl) : undefined
|
||||
vi.doMock('@/lib/core/config/redis', () => ({
|
||||
@@ -319,7 +318,7 @@ describe('isolated-vm scheduler', () => {
|
||||
expect(result.error?.message).toContain('Too many concurrent')
|
||||
})
|
||||
|
||||
it('fails closed when Redis is configured but unavailable', async () => {
|
||||
it('falls back to local execution when Redis is configured but unavailable', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
@@ -328,7 +327,7 @@ describe('isolated-vm scheduler', () => {
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "blocked"',
|
||||
code: 'return "ok"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
@@ -337,10 +336,11 @@ describe('isolated-vm scheduler', () => {
|
||||
ownerKey: 'user:redis-down',
|
||||
})
|
||||
|
||||
expect(result.error?.message).toContain('temporarily unavailable')
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.result).toBe('ok')
|
||||
})
|
||||
|
||||
it('fails closed when Redis lease evaluation errors', async () => {
|
||||
it('falls back to local execution when Redis lease evaluation errors', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
@@ -356,7 +356,7 @@ describe('isolated-vm scheduler', () => {
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "blocked"',
|
||||
code: 'return "ok"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
@@ -365,7 +365,8 @@ describe('isolated-vm scheduler', () => {
|
||||
ownerKey: 'user:redis-error',
|
||||
})
|
||||
|
||||
expect(result.error?.message).toContain('temporarily unavailable')
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.result).toBe('ok')
|
||||
})
|
||||
|
||||
it('applies weighted owner scheduling when draining queued executions', async () => {
|
||||
|
||||
@@ -987,15 +987,8 @@ export async function executeInIsolatedVM(
|
||||
}
|
||||
}
|
||||
if (leaseAcquireResult === 'unavailable') {
|
||||
maybeCleanupOwner(ownerKey)
|
||||
return {
|
||||
result: null,
|
||||
stdout: '',
|
||||
error: {
|
||||
message: 'Code execution is temporarily unavailable. Please try again in a moment.',
|
||||
name: 'Error',
|
||||
},
|
||||
}
|
||||
logger.warn('Distributed lease unavailable, falling back to local execution', { ownerKey })
|
||||
// Continue execution — local pool still enforces per-process concurrency limits
|
||||
}
|
||||
|
||||
let settled = false
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
function getOrigin(request: NextRequest): string {
|
||||
return request.nextUrl.origin
|
||||
function getOrigin(): string {
|
||||
return getBaseUrl().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function createMcpAuthorizationServerMetadataResponse(request: NextRequest): NextResponse {
|
||||
const origin = getOrigin(request)
|
||||
export function createMcpAuthorizationServerMetadataResponse(): NextResponse {
|
||||
const origin = getOrigin()
|
||||
const resource = `${origin}/api/mcp/copilot`
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
issuer: resource,
|
||||
token_endpoint: `${origin}/api/auth/oauth/token`,
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/api/auth/oauth2/authorize`,
|
||||
token_endpoint: `${origin}/api/auth/oauth2/token`,
|
||||
registration_endpoint: `${origin}/api/auth/oauth2/register`,
|
||||
jwks_uri: `${origin}/api/auth/jwks`,
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
scopes_supported: ['mcp:tools'],
|
||||
scopes_supported: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
|
||||
resource,
|
||||
// Non-standard extension for API-key-only clients.
|
||||
x_sim_auth: {
|
||||
type: 'api_key',
|
||||
header: 'x-api-key',
|
||||
@@ -32,10 +35,10 @@ export function createMcpAuthorizationServerMetadataResponse(request: NextReques
|
||||
)
|
||||
}
|
||||
|
||||
export function createMcpProtectedResourceMetadataResponse(request: NextRequest): NextResponse {
|
||||
const origin = getOrigin(request)
|
||||
export function createMcpProtectedResourceMetadataResponse(): NextResponse {
|
||||
const origin = getOrigin()
|
||||
const resource = `${origin}/api/mcp/copilot`
|
||||
const authorizationServerIssuer = `${origin}/api/mcp/copilot`
|
||||
const authorizationServerIssuer = origin
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -679,6 +679,55 @@ async function downloadSlackFiles(
|
||||
return downloaded
|
||||
}
|
||||
|
||||
const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed'])
|
||||
|
||||
/**
|
||||
* Fetches the text of a reacted-to message from Slack using the reactions.get API.
|
||||
* Unlike conversations.history, reactions.get works for both top-level messages and
|
||||
* thread replies, since it looks up the item directly by channel + timestamp.
|
||||
* Requires the bot token to have the reactions:read scope.
|
||||
*/
|
||||
async function fetchSlackMessageText(
|
||||
channel: string,
|
||||
messageTs: string,
|
||||
botToken: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
channel,
|
||||
timestamp: messageTs,
|
||||
})
|
||||
const response = await fetch(`https://slack.com/api/reactions.get?${params}`, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
})
|
||||
|
||||
const data = (await response.json()) as {
|
||||
ok: boolean
|
||||
error?: string
|
||||
type?: string
|
||||
message?: { text?: string }
|
||||
}
|
||||
|
||||
if (!data.ok) {
|
||||
logger.warn('Slack reactions.get failed — message text unavailable', {
|
||||
channel,
|
||||
messageTs,
|
||||
error: data.error,
|
||||
})
|
||||
return ''
|
||||
}
|
||||
|
||||
return data.message?.text ?? ''
|
||||
} catch (error) {
|
||||
logger.warn('Error fetching Slack message text', {
|
||||
channel,
|
||||
messageTs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format webhook input based on provider
|
||||
*/
|
||||
@@ -953,6 +1002,23 @@ export async function formatWebhookInput(
|
||||
})
|
||||
}
|
||||
|
||||
const eventType: string = rawEvent?.type || body?.type || 'unknown'
|
||||
const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType)
|
||||
|
||||
// Reaction events nest channel/ts inside event.item
|
||||
const channel: string = isReactionEvent
|
||||
? rawEvent?.item?.channel || ''
|
||||
: rawEvent?.channel || ''
|
||||
const messageTs: string = isReactionEvent
|
||||
? rawEvent?.item?.ts || ''
|
||||
: rawEvent?.ts || rawEvent?.event_ts || ''
|
||||
|
||||
// For reaction events, attempt to fetch the original message text
|
||||
let text: string = rawEvent?.text || ''
|
||||
if (isReactionEvent && channel && messageTs && botToken) {
|
||||
text = await fetchSlackMessageText(channel, messageTs, botToken)
|
||||
}
|
||||
|
||||
const rawFiles: any[] = rawEvent?.files ?? []
|
||||
const hasFiles = rawFiles.length > 0
|
||||
|
||||
@@ -965,16 +1031,18 @@ export async function formatWebhookInput(
|
||||
|
||||
return {
|
||||
event: {
|
||||
event_type: rawEvent?.type || body?.type || 'unknown',
|
||||
channel: rawEvent?.channel || '',
|
||||
event_type: eventType,
|
||||
channel,
|
||||
channel_name: '',
|
||||
user: rawEvent?.user || '',
|
||||
user_name: '',
|
||||
text: rawEvent?.text || '',
|
||||
timestamp: rawEvent?.ts || rawEvent?.event_ts || '',
|
||||
text,
|
||||
timestamp: messageTs,
|
||||
thread_ts: rawEvent?.thread_ts || '',
|
||||
team_id: body?.team_id || rawEvent?.team || '',
|
||||
event_id: body?.event_id || '',
|
||||
reaction: rawEvent?.reaction || '',
|
||||
item_user: rawEvent?.item_user || '',
|
||||
hasFiles,
|
||||
files,
|
||||
},
|
||||
|
||||
@@ -121,6 +121,14 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/.well-known/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// API routes CORS headers
|
||||
source: '/api/:path*',
|
||||
@@ -137,7 +145,52 @@ const nextConfig: NextConfig = {
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value:
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key',
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/oauth2/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization, Accept',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/jwks',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/.well-known/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/mcp/copilot',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: 'GET, POST, OPTIONS, DELETE',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -467,25 +467,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{
|
||||
id: 'claude-3-7-sonnet-latest',
|
||||
pricing: {
|
||||
input: 3.0,
|
||||
cachedInput: 0.3,
|
||||
output: 15.0,
|
||||
updatedAt: '2026-02-05',
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
computerUse: true,
|
||||
maxOutputTokens: 64000,
|
||||
thinking: {
|
||||
levels: ['low', 'medium', 'high'],
|
||||
default: 'high',
|
||||
},
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
'azure-openai': {
|
||||
|
||||
@@ -183,7 +183,6 @@ describe('Model Capabilities', () => {
|
||||
'gemini-2.5-flash',
|
||||
'claude-sonnet-4-0',
|
||||
'claude-opus-4-0',
|
||||
'claude-3-7-sonnet-latest',
|
||||
'grok-3-latest',
|
||||
'grok-3-fast-latest',
|
||||
'deepseek-v3',
|
||||
@@ -260,7 +259,6 @@ describe('Model Capabilities', () => {
|
||||
const modelsRange01 = [
|
||||
'claude-sonnet-4-0',
|
||||
'claude-opus-4-0',
|
||||
'claude-3-7-sonnet-latest',
|
||||
'grok-3-latest',
|
||||
'grok-3-fast-latest',
|
||||
]
|
||||
|
||||
11
apps/sim/public/landing/blocks-left.svg
Normal 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 |
11
apps/sim/public/landing/blocks-right.svg
Normal 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 |
20
apps/sim/public/landing/blocks-top-right.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width="295" height="34" viewBox="0 0 295 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="16.8626" height="33.7252" rx="2.59574" fill="#2ABBF8"/>
|
||||
<rect x="34.2403" y="0" width="34.2403" height="33.7252" rx="2.59574" opacity="0.6" fill="#2ABBF8"/>
|
||||
<rect x="106.268" y="0" width="34.2403" height="33.7252" rx="2.59574" opacity="0.6" fill="#00F701"/>
|
||||
<rect x="209.137" y="0" width="16.8626" height="33.7252" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
|
||||
<rect x="243.233" y="0" width="34.2403" height="33.7252" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
|
||||
<rect x="0" y="0" width="85.3433" height="16.8626" rx="2.59574" opacity="0.6" fill="#2ABBF8"/>
|
||||
<rect x="68.4812" y="0" width="54.6502" height="16.8626" rx="2.59574" fill="#00F701"/>
|
||||
<rect x="106.268" y="0" width="51.103" height="16.8626" rx="2.59574" opacity="0.6" fill="#00F701"/>
|
||||
<rect x="157.371" y="0" width="34.2403" height="16.8626" rx="2.59574" opacity="0.6" fill="#FFCC02"/>
|
||||
<rect x="208.993" y="0" width="68.4805" height="16.8626" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
|
||||
<rect x="260.096" y="0" width="34.04" height="16.8626" rx="2.59574" opacity="0.6" fill="#FA4EDF"/>
|
||||
<rect x="0" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#2ABBF8"/>
|
||||
<rect x="34.2403" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#2ABBF8"/>
|
||||
<rect x="157.371" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#FFCC02"/>
|
||||
<rect x="243.233" y="0" width="16.8626" height="16.8626" rx="2.59574" fill="#FA4EDF"/>
|
||||
<rect x="51.6188" y="16.8626" width="16.8626" height="16.8626" rx="2.59574" fill="#2ABBF8"/>
|
||||
<rect x="123.6484" y="16.8626" width="16.8626" height="16.8626" rx="2.59574" fill="#00F701"/>
|
||||
<rect x="260.611" y="16.8626" width="16.8626" height="16.8626" rx="2.59574" fill="#FA4EDF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
3
apps/sim/public/landing/card-left.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="344" height="328" viewBox="0 0 344 328" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 513 B |
3
apps/sim/public/landing/card-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="471" height="470" viewBox="0 0 471 470" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
73
apps/sim/public/landing/collaboration-visual.svg
Normal file
|
After Width: | Height: | Size: 58 KiB |
8
apps/sim/public/landing/features-transition.svg
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
apps/sim/public/landing/multiplayer-cover-thumb.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
apps/sim/public/landing/multiplayer-cover.png
Normal file
|
After Width: | Height: | Size: 963 KiB |
20
apps/sim/public/landing/multiplayer-cursors.svg
Normal file
|
After Width: | Height: | Size: 45 KiB |