mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
65 Commits
feat/table
...
v0.6.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7b5b528c | ||
|
|
cef321bda2 | ||
|
|
1809b3801b | ||
|
|
bc111a6d5c | ||
|
|
12908c14be | ||
|
|
638063cac1 | ||
|
|
5f7a980c5f | ||
|
|
a2c08e19a8 | ||
|
|
30f2d1a0fc | ||
|
|
5332614a19 | ||
|
|
ff5d90e0c0 | ||
|
|
8b245693e2 | ||
|
|
4bd0731871 | ||
|
|
60bb9422ca | ||
|
|
8a4c161ec4 | ||
|
|
b84f30e9e7 | ||
|
|
28de28899a | ||
|
|
168cd585cb | ||
|
|
5f89c7140c | ||
|
|
2bc11a70ba | ||
|
|
67478bbc80 | ||
|
|
c9f082da1a | ||
|
|
75a3e2c3a8 | ||
|
|
cdd0f75cd5 | ||
|
|
4f3bc37fe4 | ||
|
|
25a03f1f3c | ||
|
|
35c42ba227 | ||
|
|
84d6fdc423 | ||
|
|
3bd2750d22 | ||
|
|
70d8df5a19 | ||
|
|
101fcec135 | ||
|
|
1873f2d775 | ||
|
|
3e3c160789 | ||
|
|
8fa4f3fdbb | ||
|
|
b3d9e54bb2 | ||
|
|
b930ee311f | ||
|
|
e804ea356c | ||
|
|
2a7b07e3b4 | ||
|
|
974cc66b0e | ||
|
|
c867801988 | ||
|
|
c090c821be | ||
|
|
36e502a068 | ||
|
|
4c12914d35 | ||
|
|
e9bdc57616 | ||
|
|
36612ae42a | ||
|
|
1c2c2c65d4 | ||
|
|
ecd3536a72 | ||
|
|
8c0a2e04b1 | ||
|
|
6586c5ce40 | ||
|
|
3ce947566d | ||
|
|
70c36cb7aa | ||
|
|
f1ec5fe824 | ||
|
|
e07e3c34cc | ||
|
|
0d2e6ff31d | ||
|
|
4fd0989264 | ||
|
|
67f8a687f6 | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
15ace5e63f | ||
|
|
fdca73679d | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
@@ -1,9 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Viewport } from 'next'
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
|
||||
],
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
@@ -12,6 +24,9 @@ export const metadata = {
|
||||
},
|
||||
description:
|
||||
'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.',
|
||||
applicationName: 'Sim Docs',
|
||||
generator: 'Next.js',
|
||||
referrer: 'origin-when-cross-origin' as const,
|
||||
keywords: [
|
||||
'AI agents',
|
||||
'agentic workforce',
|
||||
@@ -37,17 +52,28 @@ export const metadata = {
|
||||
manifest: '/favicon/site.webmanifest',
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' },
|
||||
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/favicon/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/favicon/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
apple: '/favicon/apple-touch-icon.png',
|
||||
shortcut: '/favicon/favicon.ico',
|
||||
shortcut: '/icon.svg',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'Sim Docs',
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
other: {
|
||||
'apple-mobile-web-app-capable': 'yes',
|
||||
'mobile-web-app-capable': 'yes',
|
||||
'msapplication-TileColor': '#33C482',
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
|
||||
@@ -1146,6 +1146,25 @@ export function DevinIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function DocuSignIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 1547 1549' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='m1113.4 1114.9v395.6c0 20.8-16.7 37.6-37.5 37.6h-1038.4c-20.7 0-37.5-16.8-37.5-37.6v-1039c0-20.7 16.8-37.5 37.5-37.5h394.3v643.4c0 20.7 16.8 37.5 37.5 37.5z'
|
||||
fill='#4c00ff'
|
||||
/>
|
||||
<path
|
||||
d='m1546 557.1c0 332.4-193.9 557-432.6 557.8v-418.8c0-12-4.8-24-13.5-31.9l-217.1-217.4c-8.8-8.8-20-13.6-32-13.6h-418.2v-394.8c0-20.8 16.8-37.6 37.5-37.6h585.1c277.7-0.8 490.8 223 490.8 556.3z'
|
||||
fill='#ff5252'
|
||||
/>
|
||||
<path
|
||||
d='m1099.9 663.4c8.7 8.7 13.5 19.9 13.5 31.9v418.8h-643.3c-20.7 0-37.5-16.8-37.5-37.5v-643.4h418.2c12 0 24 4.8 32 13.6z'
|
||||
fill='#000000'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -1390,7 +1409,7 @@ export function AmplitudeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 49 49'>
|
||||
<path
|
||||
fill='#FFFFFF'
|
||||
fill='currentColor'
|
||||
d='M23.4,15.3c0.6,1.8,1.2,4.1,1.9,6.7c-2.6,0-5.3-0.1-7.8-0.1h-1.3c1.5-5.7,3.2-10.1,4.6-11.1 c0.1-0.1,0.2-0.1,0.4-0.1c0.2,0,0.3,0.1,0.5,0.3C21.9,11.5,22.5,12.7,23.4,15.3z M49,24.5C49,38,38,49,24.5,49S0,38,0,24.5 S11,0,24.5,0S49,11,49,24.5z M42.7,23.9c0-0.6-0.4-1.2-1-1.3l0,0l0,0l0,0c-0.1,0-0.1,0-0.2,0h-0.2c-4.1-0.3-8.4-0.4-12.4-0.5l0,0 C27,14.8,24.5,7.4,21.3,7.4c-3,0-5.8,4.9-8.2,14.5c-1.7,0-3.2,0-4.6-0.1c-0.1,0-0.2,0-0.2,0c-0.3,0-0.5,0-0.5,0 c-0.8,0.1-1.4,0.9-1.4,1.7c0,0.8,0.6,1.6,1.5,1.7l0,0h4.6c-0.4,1.9-0.8,3.8-1.1,5.6l-0.1,0.8l0,0c0,0.6,0.5,1.1,1.1,1.1 c0.4,0,0.8-0.2,1-0.5l0,0l2.2-7.1h10.7c0.8,3.1,1.7,6.3,2.8,9.3c0.6,1.6,2,5.4,4.4,5.4l0,0c3.6,0,5-5.8,5.9-9.6 c0.2-0.8,0.4-1.5,0.5-2.1l0.1-0.2l0,0c0-0.1,0-0.2,0-0.3c-0.1-0.2-0.2-0.3-0.4-0.4c-0.3-0.1-0.5,0.1-0.6,0.4l0,0l-0.1,0.2 c-0.3,0.8-0.6,1.6-0.8,2.3v0.1c-1.6,4.4-2.3,6.4-3.7,6.4l0,0l0,0l0,0c-1.8,0-3.5-7.3-4.1-10.1c-0.1-0.5-0.2-0.9-0.3-1.3h11.7 c0.2,0,0.4-0.1,0.6-0.1l0,0c0,0,0,0,0.1,0c0,0,0,0,0.1,0l0,0c0,0,0.1,0,0.1-0.1l0,0C42.5,24.6,42.7,24.3,42.7,23.9z'
|
||||
/>
|
||||
</svg>
|
||||
@@ -4569,11 +4588,17 @@ export function ShopifyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function BoxCompanyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 41 22'>
|
||||
<path
|
||||
d='M39.7 19.2c.5.7.4 1.6-.2 2.1-.7.5-1.7.4-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2-.7-.5-.8-1.4-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2.7-.5 1.7-.3 2.2.3l3.4 4.5L37.3 7c.5-.7 1.4-.8 2.2-.3.7.5.7 1.5.2 2.2L35.8 14l3.9 5.2zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c-.1 2.6-2.2 4.6-4.7 4.6zm-13.8 0c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6zM21.5 6.4c-2.9 0-5.5 1.6-6.8 4-1.3-2.4-3.9-4-6.9-4-1.8 0-3.4.6-4.7 1.5V1.5C3.1.7 2.4 0 1.6 0 .7 0 0 .7 0 1.5v12.6c.1 4.2 3.5 7.5 7.7 7.5 3 0 5.6-1.7 6.9-4.1 1.3 2.4 3.9 4.1 6.8 4.1 4.3 0 7.8-3.4 7.8-7.7.1-4.1-3.4-7.5-7.7-7.5z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='2500'
|
||||
height='1379'
|
||||
viewBox='0 0 444.893 245.414'
|
||||
>
|
||||
<g fill='#0075C9'>
|
||||
<path d='M239.038 72.43c-33.081 0-61.806 18.6-76.322 45.904-14.516-27.305-43.24-45.902-76.32-45.902-19.443 0-37.385 6.424-51.821 17.266V16.925h-.008C34.365 7.547 26.713 0 17.286 0 7.858 0 .208 7.547.008 16.925H0v143.333h.036c.768 47.051 39.125 84.967 86.359 84.967 33.08 0 61.805-18.603 76.32-45.908 14.517 27.307 43.241 45.906 76.321 45.906 47.715 0 86.396-38.684 86.396-86.396.001-47.718-38.682-86.397-86.394-86.397zM86.395 210.648c-28.621 0-51.821-23.201-51.821-51.82 0-28.623 23.201-51.823 51.821-51.823 28.621 0 51.822 23.2 51.822 51.823 0 28.619-23.201 51.82-51.822 51.82zm152.643 0c-28.622 0-51.821-23.201-51.821-51.822 0-28.623 23.2-51.821 51.821-51.821 28.619 0 51.822 23.198 51.822 51.821-.001 28.621-23.203 51.822-51.822 51.822z' />
|
||||
<path d='M441.651 218.033l-44.246-59.143 44.246-59.144-.008-.007c5.473-7.62 3.887-18.249-3.652-23.913-7.537-5.658-18.187-4.221-23.98 3.157l-.004-.002-38.188 51.047-38.188-51.047-.006.009c-5.793-7.385-16.441-8.822-23.981-3.16-7.539 5.664-9.125 16.293-3.649 23.911l-.008.005 44.245 59.144-44.245 59.143.008.005c-5.477 7.62-3.89 18.247 3.649 23.909 7.54 5.664 18.188 4.225 23.981-3.155l.006.007 38.188-51.049 38.188 51.049.004-.002c5.794 7.377 16.443 8.814 23.98 3.154 7.539-5.662 9.125-16.291 3.652-23.91l.008-.008z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AsanaIcon,
|
||||
AshbyIcon,
|
||||
AttioIcon,
|
||||
BoxCompanyIcon,
|
||||
BrainIcon,
|
||||
BrandfetchIcon,
|
||||
BrowserUseIcon,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
DevinIcon,
|
||||
DiscordIcon,
|
||||
DocumentIcon,
|
||||
DocuSignIcon,
|
||||
DropboxIcon,
|
||||
DsPyIcon,
|
||||
DubIcon,
|
||||
@@ -184,6 +186,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
asana: AsanaIcon,
|
||||
ashby: AshbyIcon,
|
||||
attio: AttioIcon,
|
||||
box: BoxCompanyIcon,
|
||||
brandfetch: BrandfetchIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
calcom: CalComIcon,
|
||||
@@ -198,6 +201,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
datadog: DatadogIcon,
|
||||
devin: DevinIcon,
|
||||
discord: DiscordIcon,
|
||||
docusign: DocuSignIcon,
|
||||
dropbox: DropboxIcon,
|
||||
dspy: DsPyIcon,
|
||||
dub: DubIcon,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"mailer",
|
||||
"skills",
|
||||
"knowledgebase",
|
||||
"tables",
|
||||
"variables",
|
||||
"credentials",
|
||||
"execution",
|
||||
|
||||
158
apps/docs/content/docs/en/tables/index.mdx
Normal file
158
apps/docs/content/docs/en/tables/index.mdx
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Tables
|
||||
description: Store, query, and manage structured data directly within your workspace
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Tables let you store and manage structured data directly in your workspace. Use them to maintain reference data, collect workflow outputs, or build lightweight databases — all without leaving Sim.
|
||||
|
||||
<Image src="/static/tables/tables-overview.png" alt="Tables view showing structured data with typed columns for name, title, company, role, and more" width={800} height={500} />
|
||||
|
||||
Each table has a schema of typed columns, supports filtering and sorting, and is fully accessible through the [Tables API](/docs/en/api-reference/(generated)/tables).
|
||||
|
||||
## Creating a Table
|
||||
|
||||
1. Open the **Tables** section from your workspace sidebar
|
||||
2. Click **New table**
|
||||
3. Name your table and start adding columns
|
||||
|
||||
Tables start with a single text column. Add more columns by clicking **New column** in the column header area.
|
||||
|
||||
## Column Types
|
||||
|
||||
Each column has a type that determines how values are stored and validated.
|
||||
|
||||
| Type | Description | Example Values |
|
||||
|------|-------------|----------------|
|
||||
| **Text** | Free-form string | `"Acme Corp"`, `"hello@example.com"` |
|
||||
| **Number** | Numeric value | `42`, `3.14`, `-100` |
|
||||
| **Boolean** | True or false | `true`, `false` |
|
||||
| **Date** | Date value | `2026-03-16` |
|
||||
| **JSON** | Structured object or array | `{"key": "value"}`, `[1, 2, 3]` |
|
||||
|
||||
<Callout type="info">
|
||||
Column types are enforced on input. For example, typing into a Number column is restricted to digits, dots, and minus signs. Non-numeric values entered via paste are coerced to `0`.
|
||||
</Callout>
|
||||
|
||||
## Working with Rows
|
||||
|
||||
### Adding Rows
|
||||
|
||||
- Click **New row** below the last row to append a new row
|
||||
- Press **Shift + Enter** while a cell is selected to insert a row below
|
||||
- Paste tabular data (from a spreadsheet or TSV) to bulk-create rows
|
||||
|
||||
### Editing Cells
|
||||
|
||||
Click a cell to select it, then press **Enter**, **F2**, or start typing to edit. Press **Escape** to cancel, or **Tab** to save and move to the next cell.
|
||||
|
||||
### Selecting Rows
|
||||
|
||||
Click a row's checkbox to select it. Selecting additional checkboxes adds to the selection without clearing previous selections.
|
||||
|
||||
| Action | Behavior |
|
||||
|--------|----------|
|
||||
| Click checkbox | Toggle that row's selection |
|
||||
| Shift + click checkbox | Select range from last clicked to current |
|
||||
| Click header checkbox | Select all / deselect all |
|
||||
| Shift + Space | Toggle row selection from keyboard |
|
||||
|
||||
### Deleting Rows
|
||||
|
||||
Right-click a selected row (or group of selected rows) and choose **Delete row** from the context menu.
|
||||
|
||||
## Filtering and Sorting
|
||||
|
||||
Use the toolbar above the table to filter and sort your data.
|
||||
|
||||
- **Filter**: Set conditions on any column (e.g., "Name contains Acme"). Multiple filters are combined with AND logic.
|
||||
- **Sort**: Order rows by any column, ascending or descending.
|
||||
|
||||
Filters and sorts are applied in real time and do not modify the underlying data.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
All shortcuts work when the table is focused and no cell is being edited.
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
|
||||
</Callout>
|
||||
|
||||
### Navigation
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Arrow keys | Move one cell |
|
||||
| `Mod` + Arrow keys | Jump to edge of table |
|
||||
| `Tab` / `Shift` + `Tab` | Move to next / previous cell |
|
||||
| `Escape` | Clear selection |
|
||||
|
||||
### Selection
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Shift` + Arrow keys | Extend selection by one cell |
|
||||
| `Mod` + `Shift` + Arrow keys | Extend selection to edge |
|
||||
| `Mod` + `A` | Select all rows |
|
||||
| `Shift` + `Space` | Toggle current row selection |
|
||||
|
||||
### Editing
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Enter` or `F2` | Start editing selected cell |
|
||||
| `Escape` | Cancel editing |
|
||||
| Type any character | Start editing with that character |
|
||||
| `Shift` + `Enter` | Insert new row below |
|
||||
| `Space` | Expand row details |
|
||||
|
||||
### Clipboard
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `C` | Copy selected cells |
|
||||
| `Mod` + `X` | Cut selected cells |
|
||||
| `Mod` + `V` | Paste |
|
||||
| `Delete` / `Backspace` | Clear selected cells (all columns when using checkbox selection) |
|
||||
|
||||
### History
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `Z` | Undo |
|
||||
| `Mod` + `Shift` + `Z` | Redo |
|
||||
| `Mod` + `Y` | Redo (alternative) |
|
||||
|
||||
## Using Tables in Workflows
|
||||
|
||||
Tables can be read from and written to within your workflows using the **Table** block. Common patterns include:
|
||||
|
||||
- **Lookup**: Query a table for reference data (e.g., pricing rules, customer metadata)
|
||||
- **Write-back**: Store workflow outputs in a table for later review or reporting
|
||||
- **Iteration**: Process each row in a table as part of a batch workflow
|
||||
|
||||
## API Access
|
||||
|
||||
Tables are fully accessible through the REST API. You can create, read, update, and delete both tables and rows programmatically.
|
||||
|
||||
See the [Tables API Reference](/docs/en/api-reference/(generated)/tables) for endpoints, parameters, and examples.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use typed columns** to enforce data integrity — prefer Number and Boolean over storing everything as Text
|
||||
- **Name columns descriptively** so they are self-documenting when referenced in workflows
|
||||
- **Use JSON columns sparingly** — they are flexible but harder to filter and sort against
|
||||
- **Leverage the API** for bulk imports rather than manually entering large datasets
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Is there a row limit per table?", answer: "Tables are designed for working datasets. For very large datasets (100k+ rows), consider paginating API reads or splitting data across multiple tables." },
|
||||
{ question: "Can I import data from a spreadsheet?", answer: "Yes. Copy rows from any spreadsheet application and paste them directly into the table. Column values will be validated against the column types." },
|
||||
{ question: "Do tables support formulas?", answer: "Tables store raw data and do not support computed formulas. Use workflow logic (Function block or Agent block) to derive computed values and write them back to the table." },
|
||||
{ question: "Can multiple workflows write to the same table?", answer: "Yes. Table writes are atomic at the row level, so multiple workflows can safely write to the same table concurrently." },
|
||||
{ question: "How do I reference a table from a workflow?", answer: "Use the Table block in your workflow. Select the target table from the dropdown, choose an operation (read, write, update), and configure the parameters." },
|
||||
{ question: "Are tables shared across workspace members?", answer: "Yes. Tables are workspace-scoped and accessible to all members with appropriate permissions." },
|
||||
{ question: "Can I undo changes?", answer: "In the table editor, Cmd/Ctrl+Z undoes recent cell edits, row insertions, and row deletions. API-driven changes are not covered by the editor's undo history." },
|
||||
]} />
|
||||
440
apps/docs/content/docs/en/tools/box.mdx
Normal file
440
apps/docs/content/docs/en/tools/box.mdx
Normal file
@@ -0,0 +1,440 @@
|
||||
---
|
||||
title: Box
|
||||
description: Manage files, folders, and e-signatures with Box
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="box"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Box](https://www.box.com/) is a leading cloud content management and file sharing platform trusted by enterprises worldwide to securely store, manage, and collaborate on files. Box provides robust APIs for automating file operations and integrating with business workflows, including [Box Sign](https://www.box.com/esignature) for native e-signatures.
|
||||
|
||||
With the Box integration in Sim, you can:
|
||||
|
||||
- **Upload files**: Upload documents, images, and other files to any Box folder
|
||||
- **Download files**: Retrieve file content from Box for processing in your workflows
|
||||
- **Get file info**: Access detailed metadata including size, owner, timestamps, tags, and shared links
|
||||
- **List folder contents**: Browse files and folders with sorting and pagination support
|
||||
- **Create folders**: Organize your Box storage by creating new folders programmatically
|
||||
- **Delete files and folders**: Remove content with optional recursive deletion for folders
|
||||
- **Copy files**: Duplicate files across folders with optional renaming
|
||||
- **Search**: Find files and folders by name, content, extension, or location
|
||||
- **Update file metadata**: Rename, move, add descriptions, or tag files
|
||||
- **Create sign requests**: Send documents for e-signature with one or more signers
|
||||
- **Track signing status**: Monitor the progress of sign requests
|
||||
- **List sign requests**: View all sign requests with marker-based pagination
|
||||
- **Cancel sign requests**: Cancel pending sign requests that are no longer needed
|
||||
- **Resend sign reminders**: Send reminder notifications to signers who haven't completed signing
|
||||
|
||||
These capabilities allow your Sim agents to automate Box operations directly within your workflows — from organizing documents and distributing content to processing uploaded files, managing e-signature workflows for offer letters and contracts, and maintaining structured cloud storage as part of your business processes.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Box into your workflow to manage files, folders, and e-signatures. Upload and download files, search content, create folders, send documents for e-signature, track signing status, and more.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `box_upload_file`
|
||||
|
||||
Upload a file to a Box folder
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `parentFolderId` | string | Yes | The ID of the folder to upload the file to \(use "0" for root\) |
|
||||
| `file` | file | No | The file to upload \(UserFile object\) |
|
||||
| `fileContent` | string | No | Legacy: base64 encoded file content |
|
||||
| `fileName` | string | No | Optional filename override |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | File ID |
|
||||
| `name` | string | File name |
|
||||
| `size` | number | File size in bytes |
|
||||
| `sha1` | string | SHA1 hash of file content |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `modifiedAt` | string | Last modified timestamp |
|
||||
| `parentId` | string | Parent folder ID |
|
||||
| `parentName` | string | Parent folder name |
|
||||
|
||||
### `box_download_file`
|
||||
|
||||
Download a file from Box
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to download |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `content` | string | Base64 encoded file content |
|
||||
|
||||
### `box_get_file_info`
|
||||
|
||||
Get detailed information about a file in Box
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to get information about |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | File ID |
|
||||
| `name` | string | File name |
|
||||
| `description` | string | File description |
|
||||
| `size` | number | File size in bytes |
|
||||
| `sha1` | string | SHA1 hash of file content |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `modifiedAt` | string | Last modified timestamp |
|
||||
| `createdBy` | object | User who created the file |
|
||||
| `modifiedBy` | object | User who last modified the file |
|
||||
| `ownedBy` | object | User who owns the file |
|
||||
| `parentId` | string | Parent folder ID |
|
||||
| `parentName` | string | Parent folder name |
|
||||
| `sharedLink` | json | Shared link details |
|
||||
| `tags` | array | File tags |
|
||||
| `commentCount` | number | Number of comments |
|
||||
|
||||
### `box_list_folder_items`
|
||||
|
||||
List files and folders in a Box folder
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `folderId` | string | Yes | The ID of the folder to list items from \(use "0" for root\) |
|
||||
| `limit` | number | No | Maximum number of items to return per page |
|
||||
| `offset` | number | No | The offset for pagination |
|
||||
| `sort` | string | No | Sort field: id, name, date, or size |
|
||||
| `direction` | string | No | Sort direction: ASC or DESC |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `entries` | array | List of items in the folder |
|
||||
| ↳ `type` | string | Item type \(file, folder, web_link\) |
|
||||
| ↳ `id` | string | Item ID |
|
||||
| ↳ `name` | string | Item name |
|
||||
| ↳ `size` | number | Item size in bytes |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `modifiedAt` | string | Last modified timestamp |
|
||||
| `totalCount` | number | Total number of items in the folder |
|
||||
| `offset` | number | Current pagination offset |
|
||||
| `limit` | number | Current pagination limit |
|
||||
|
||||
### `box_create_folder`
|
||||
|
||||
Create a new folder in Box
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `name` | string | Yes | Name for the new folder |
|
||||
| `parentFolderId` | string | Yes | The ID of the parent folder \(use "0" for root\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Folder ID |
|
||||
| `name` | string | Folder name |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `modifiedAt` | string | Last modified timestamp |
|
||||
| `parentId` | string | Parent folder ID |
|
||||
| `parentName` | string | Parent folder name |
|
||||
|
||||
### `box_delete_file`
|
||||
|
||||
Delete a file from Box
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the file was successfully deleted |
|
||||
| `message` | string | Success confirmation message |
|
||||
|
||||
### `box_delete_folder`
|
||||
|
||||
Delete a folder from Box
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `folderId` | string | Yes | The ID of the folder to delete |
|
||||
| `recursive` | boolean | No | Delete folder and all its contents recursively |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the folder was successfully deleted |
|
||||
| `message` | string | Success confirmation message |
|
||||
|
||||
### `box_copy_file`
|
||||
|
||||
Copy a file to another folder in Box
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to copy |
|
||||
| `parentFolderId` | string | Yes | The ID of the destination folder |
|
||||
| `name` | string | No | Optional new name for the copied file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | File ID |
|
||||
| `name` | string | File name |
|
||||
| `size` | number | File size in bytes |
|
||||
| `sha1` | string | SHA1 hash of file content |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `modifiedAt` | string | Last modified timestamp |
|
||||
| `parentId` | string | Parent folder ID |
|
||||
| `parentName` | string | Parent folder name |
|
||||
|
||||
### `box_search`
|
||||
|
||||
Search for files and folders in Box
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `query` | string | Yes | The search query string |
|
||||
| `limit` | number | No | Maximum number of results to return |
|
||||
| `offset` | number | No | The offset for pagination |
|
||||
| `ancestorFolderId` | string | No | Restrict search to a specific folder and its subfolders |
|
||||
| `fileExtensions` | string | No | Comma-separated file extensions to filter by \(e.g., pdf,docx\) |
|
||||
| `type` | string | No | Restrict to a specific content type: file, folder, or web_link |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | array | Search results |
|
||||
| ↳ `type` | string | Item type \(file, folder, web_link\) |
|
||||
| ↳ `id` | string | Item ID |
|
||||
| ↳ `name` | string | Item name |
|
||||
| ↳ `size` | number | Item size in bytes |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `modifiedAt` | string | Last modified timestamp |
|
||||
| ↳ `parentId` | string | Parent folder ID |
|
||||
| ↳ `parentName` | string | Parent folder name |
|
||||
| `totalCount` | number | Total number of matching results |
|
||||
|
||||
### `box_update_file`
|
||||
|
||||
Update file info in Box (rename, move, change description, add tags)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to update |
|
||||
| `name` | string | No | New name for the file |
|
||||
| `description` | string | No | New description for the file \(max 256 characters\) |
|
||||
| `parentFolderId` | string | No | Move the file to a different folder by specifying the folder ID |
|
||||
| `tags` | string | No | Comma-separated tags to set on the file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | File ID |
|
||||
| `name` | string | File name |
|
||||
| `description` | string | File description |
|
||||
| `size` | number | File size in bytes |
|
||||
| `sha1` | string | SHA1 hash of file content |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `modifiedAt` | string | Last modified timestamp |
|
||||
| `createdBy` | object | User who created the file |
|
||||
| `modifiedBy` | object | User who last modified the file |
|
||||
| `ownedBy` | object | User who owns the file |
|
||||
| `parentId` | string | Parent folder ID |
|
||||
| `parentName` | string | Parent folder name |
|
||||
| `sharedLink` | json | Shared link details |
|
||||
| `tags` | array | File tags |
|
||||
| `commentCount` | number | Number of comments |
|
||||
|
||||
### `box_sign_create_request`
|
||||
|
||||
Create a new Box Sign request to send documents for e-signature
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `sourceFileIds` | string | Yes | Comma-separated Box file IDs to send for signing |
|
||||
| `signerEmail` | string | Yes | Primary signer email address |
|
||||
| `signerRole` | string | No | Primary signer role: signer, approver, or final_copy_reader \(default: signer\) |
|
||||
| `additionalSigners` | string | No | JSON array of additional signers, e.g. \[\{"email":"user@example.com","role":"signer"\}\] |
|
||||
| `parentFolderId` | string | No | Box folder ID where signed documents will be stored \(default: user root\) |
|
||||
| `emailSubject` | string | No | Custom subject line for the signing email |
|
||||
| `emailMessage` | string | No | Custom message in the signing email body |
|
||||
| `name` | string | No | Name for the sign request |
|
||||
| `daysValid` | number | No | Number of days before the request expires \(0-730\) |
|
||||
| `areRemindersEnabled` | boolean | No | Whether to send automatic signing reminders |
|
||||
| `areTextSignaturesEnabled` | boolean | No | Whether to allow typed \(text\) signatures |
|
||||
| `signatureColor` | string | No | Signature color: blue, black, or red |
|
||||
| `redirectUrl` | string | No | URL to redirect signers to after signing |
|
||||
| `declinedRedirectUrl` | string | No | URL to redirect signers to after declining |
|
||||
| `isDocumentPreparationNeeded` | boolean | No | Whether document preparation is needed before sending |
|
||||
| `externalId` | string | No | External system reference ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Sign request ID |
|
||||
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
|
||||
| `name` | string | Sign request name |
|
||||
| `shortId` | string | Human-readable short ID |
|
||||
| `signers` | array | List of signers |
|
||||
| `sourceFiles` | array | Source files for signing |
|
||||
| `emailSubject` | string | Custom email subject line |
|
||||
| `emailMessage` | string | Custom email message body |
|
||||
| `daysValid` | number | Number of days the request is valid |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `autoExpireAt` | string | Auto-expiration timestamp |
|
||||
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
|
||||
| `senderEmail` | string | Email of the sender |
|
||||
|
||||
### `box_sign_get_request`
|
||||
|
||||
Get the details and status of a Box Sign request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `signRequestId` | string | Yes | The ID of the sign request to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Sign request ID |
|
||||
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
|
||||
| `name` | string | Sign request name |
|
||||
| `shortId` | string | Human-readable short ID |
|
||||
| `signers` | array | List of signers |
|
||||
| `sourceFiles` | array | Source files for signing |
|
||||
| `emailSubject` | string | Custom email subject line |
|
||||
| `emailMessage` | string | Custom email message body |
|
||||
| `daysValid` | number | Number of days the request is valid |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `autoExpireAt` | string | Auto-expiration timestamp |
|
||||
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
|
||||
| `senderEmail` | string | Email of the sender |
|
||||
|
||||
### `box_sign_list_requests`
|
||||
|
||||
List all Box Sign requests
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `limit` | number | No | Maximum number of sign requests to return \(max 1000\) |
|
||||
| `marker` | string | No | Pagination marker from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `signRequests` | array | List of sign requests |
|
||||
| ↳ `id` | string | Sign request ID |
|
||||
| ↳ `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
|
||||
| ↳ `name` | string | Sign request name |
|
||||
| ↳ `shortId` | string | Human-readable short ID |
|
||||
| ↳ `signers` | array | List of signers |
|
||||
| ↳ `sourceFiles` | array | Source files for signing |
|
||||
| ↳ `emailSubject` | string | Custom email subject line |
|
||||
| ↳ `emailMessage` | string | Custom email message body |
|
||||
| ↳ `daysValid` | number | Number of days the request is valid |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `autoExpireAt` | string | Auto-expiration timestamp |
|
||||
| ↳ `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
|
||||
| ↳ `senderEmail` | string | Email of the sender |
|
||||
| `count` | number | Number of sign requests returned in this page |
|
||||
| `nextMarker` | string | Marker for next page of results |
|
||||
|
||||
### `box_sign_cancel_request`
|
||||
|
||||
Cancel a pending Box Sign request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `signRequestId` | string | Yes | The ID of the sign request to cancel |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Sign request ID |
|
||||
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
|
||||
| `name` | string | Sign request name |
|
||||
| `shortId` | string | Human-readable short ID |
|
||||
| `signers` | array | List of signers |
|
||||
| `sourceFiles` | array | Source files for signing |
|
||||
| `emailSubject` | string | Custom email subject line |
|
||||
| `emailMessage` | string | Custom email message body |
|
||||
| `daysValid` | number | Number of days the request is valid |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `autoExpireAt` | string | Auto-expiration timestamp |
|
||||
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
|
||||
| `senderEmail` | string | Email of the sender |
|
||||
|
||||
### `box_sign_resend_request`
|
||||
|
||||
Resend a Box Sign request to signers who have not yet signed
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `signRequestId` | string | Yes | The ID of the sign request to resend |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Success confirmation message |
|
||||
|
||||
|
||||
230
apps/docs/content/docs/en/tools/docusign.mdx
Normal file
230
apps/docs/content/docs/en/tools/docusign.mdx
Normal file
@@ -0,0 +1,230 @@
|
||||
---
|
||||
title: DocuSign
|
||||
description: Send documents for e-signature via DocuSign
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="docusign"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[DocuSign](https://www.docusign.com) is the world's leading e-signature platform, enabling businesses to send, sign, and manage agreements digitally. With its powerful eSignature REST API, DocuSign supports the full document lifecycle from creation through completion.
|
||||
|
||||
With the DocuSign integration in Sim, you can:
|
||||
|
||||
- **Send envelopes**: Create and send documents for e-signature with custom recipients and signing tabs
|
||||
- **Use templates**: Send envelopes from pre-configured DocuSign templates with role assignments
|
||||
- **Track status**: Get envelope details including signing progress, timestamps, and recipient status
|
||||
- **List envelopes**: Search and filter envelopes by date range, status, and text
|
||||
- **Download documents**: Retrieve signed documents as base64-encoded files
|
||||
- **Manage recipients**: View signer and CC recipient details and signing status
|
||||
- **Void envelopes**: Cancel in-progress envelopes with a reason
|
||||
|
||||
In Sim, the DocuSign integration enables your agents to automate document workflows end-to-end. Agents can generate agreements, send them for signature, monitor completion, and retrieve signed copies—powering contract management, HR onboarding, sales closings, and compliance processes.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Create and send envelopes for e-signature, use templates, check signing status, download signed documents, and manage recipients with DocuSign.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `docusign_send_envelope`
|
||||
|
||||
Create and send a DocuSign envelope with a document for e-signature
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `emailSubject` | string | Yes | Email subject for the envelope |
|
||||
| `emailBody` | string | No | Email body message |
|
||||
| `signerEmail` | string | Yes | Email address of the signer |
|
||||
| `signerName` | string | Yes | Full name of the signer |
|
||||
| `ccEmail` | string | No | Email address of carbon copy recipient |
|
||||
| `ccName` | string | No | Full name of carbon copy recipient |
|
||||
| `file` | file | No | Document file to send for signature |
|
||||
| `status` | string | No | Envelope status: "sent" to send immediately, "created" for draft \(default: "sent"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `envelopeId` | string | Created envelope ID |
|
||||
| `status` | string | Envelope status |
|
||||
| `statusDateTime` | string | Status change datetime |
|
||||
| `uri` | string | Envelope URI |
|
||||
|
||||
### `docusign_create_from_template`
|
||||
|
||||
Create and send a DocuSign envelope using a pre-built template
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `templateId` | string | Yes | DocuSign template ID to use |
|
||||
| `emailSubject` | string | No | Override email subject \(uses template default if not set\) |
|
||||
| `emailBody` | string | No | Override email body message |
|
||||
| `templateRoles` | string | Yes | JSON array of template roles, e.g. \[\{"roleName":"Signer","name":"John","email":"john@example.com"\}\] |
|
||||
| `status` | string | No | Envelope status: "sent" to send immediately, "created" for draft \(default: "sent"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `envelopeId` | string | Created envelope ID |
|
||||
| `status` | string | Envelope status |
|
||||
| `statusDateTime` | string | Status change datetime |
|
||||
| `uri` | string | Envelope URI |
|
||||
|
||||
### `docusign_get_envelope`
|
||||
|
||||
Get the details and status of a DocuSign envelope
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `envelopeId` | string | Yes | The envelope ID to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `envelopeId` | string | Envelope ID |
|
||||
| `status` | string | Envelope status \(created, sent, delivered, completed, declined, voided\) |
|
||||
| `emailSubject` | string | Email subject line |
|
||||
| `sentDateTime` | string | When the envelope was sent |
|
||||
| `completedDateTime` | string | When all recipients completed signing |
|
||||
| `createdDateTime` | string | When the envelope was created |
|
||||
| `statusChangedDateTime` | string | When the status last changed |
|
||||
| `voidedReason` | string | Reason the envelope was voided |
|
||||
| `signerCount` | number | Number of signers |
|
||||
| `documentCount` | number | Number of documents |
|
||||
|
||||
### `docusign_list_envelopes`
|
||||
|
||||
List envelopes from your DocuSign account with optional filters
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fromDate` | string | No | Start date filter \(ISO 8601\). Defaults to 30 days ago |
|
||||
| `toDate` | string | No | End date filter \(ISO 8601\) |
|
||||
| `envelopeStatus` | string | No | Filter by status: created, sent, delivered, completed, declined, voided |
|
||||
| `searchText` | string | No | Search text to filter envelopes |
|
||||
| `count` | string | No | Maximum number of envelopes to return \(default: 25\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `envelopes` | array | Array of DocuSign envelopes |
|
||||
| ↳ `envelopeId` | string | Unique envelope identifier |
|
||||
| ↳ `status` | string | Envelope status \(created, sent, delivered, completed, declined, voided\) |
|
||||
| ↳ `emailSubject` | string | Email subject line |
|
||||
| ↳ `sentDateTime` | string | ISO 8601 datetime when envelope was sent |
|
||||
| ↳ `completedDateTime` | string | ISO 8601 datetime when envelope was completed |
|
||||
| ↳ `createdDateTime` | string | ISO 8601 datetime when envelope was created |
|
||||
| ↳ `statusChangedDateTime` | string | ISO 8601 datetime of last status change |
|
||||
| `totalSetSize` | number | Total number of matching envelopes |
|
||||
| `resultSetSize` | number | Number of envelopes returned in this response |
|
||||
|
||||
### `docusign_void_envelope`
|
||||
|
||||
Void (cancel) a sent DocuSign envelope that has not yet been completed
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `envelopeId` | string | Yes | The envelope ID to void |
|
||||
| `voidedReason` | string | Yes | Reason for voiding the envelope |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `envelopeId` | string | Voided envelope ID |
|
||||
| `status` | string | Envelope status \(voided\) |
|
||||
|
||||
### `docusign_download_document`
|
||||
|
||||
Download a signed document from a completed DocuSign envelope
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `envelopeId` | string | Yes | The envelope ID containing the document |
|
||||
| `documentId` | string | No | Specific document ID to download, or "combined" for all documents merged \(default: "combined"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `base64Content` | string | Base64-encoded document content |
|
||||
| `mimeType` | string | MIME type of the document |
|
||||
| `fileName` | string | Original file name |
|
||||
|
||||
### `docusign_list_templates`
|
||||
|
||||
List available templates in your DocuSign account
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `searchText` | string | No | Search text to filter templates by name |
|
||||
| `count` | string | No | Maximum number of templates to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `templates` | array | Array of DocuSign templates |
|
||||
| ↳ `templateId` | string | Template identifier |
|
||||
| ↳ `name` | string | Template name |
|
||||
| ↳ `description` | string | Template description |
|
||||
| ↳ `shared` | boolean | Whether template is shared |
|
||||
| ↳ `created` | string | ISO 8601 creation date |
|
||||
| ↳ `lastModified` | string | ISO 8601 last modified date |
|
||||
| `totalSetSize` | number | Total number of matching templates |
|
||||
| `resultSetSize` | number | Number of templates returned in this response |
|
||||
|
||||
### `docusign_list_recipients`
|
||||
|
||||
Get the recipient status details for a DocuSign envelope
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `envelopeId` | string | Yes | The envelope ID to get recipients for |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `signers` | array | Array of DocuSign recipients |
|
||||
| ↳ `recipientId` | string | Recipient identifier |
|
||||
| ↳ `name` | string | Recipient name |
|
||||
| ↳ `email` | string | Recipient email address |
|
||||
| ↳ `status` | string | Recipient signing status \(sent, delivered, completed, declined\) |
|
||||
| ↳ `signedDateTime` | string | ISO 8601 datetime when recipient signed |
|
||||
| ↳ `deliveredDateTime` | string | ISO 8601 datetime when delivered to recipient |
|
||||
| `carbonCopies` | array | Array of carbon copy recipients |
|
||||
| ↳ `recipientId` | string | Recipient ID |
|
||||
| ↳ `name` | string | Recipient name |
|
||||
| ↳ `email` | string | Recipient email |
|
||||
| ↳ `status` | string | Recipient status |
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ Extract structured content from web pages with comprehensive metadata support. C
|
||||
| `url` | string | Yes | The URL to scrape content from \(e.g., "https://example.com/page"\) |
|
||||
| `scrapeOptions` | json | No | Options for content scraping |
|
||||
| `apiKey` | string | Yes | Firecrawl API key |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -86,6 +89,9 @@ Search for information on the web using Firecrawl
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `query` | string | Yes | The search query to use |
|
||||
| `apiKey` | string | Yes | Firecrawl API key |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -123,6 +129,9 @@ Crawl entire websites and extract structured content from all accessible pages
|
||||
| `includePaths` | json | No | URL paths to include in crawling \(e.g., \["/docs/*", "/api/*"\]\). Only these paths will be crawled |
|
||||
| `onlyMainContent` | boolean | No | Extract only main content from pages |
|
||||
| `apiKey` | string | Yes | Firecrawl API Key |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -142,7 +151,6 @@ Crawl entire websites and extract structured content from all accessible pages
|
||||
| ↳ `statusCode` | number | HTTP status code |
|
||||
| ↳ `ogLocaleAlternate` | array | Alternate locale versions |
|
||||
| `total` | number | Total number of pages found during crawl |
|
||||
| `creditsUsed` | number | Number of credits consumed by the crawl operation |
|
||||
|
||||
### `firecrawl_map`
|
||||
|
||||
@@ -161,6 +169,9 @@ Get a complete list of URLs from any website quickly and reliably. Useful for di
|
||||
| `timeout` | number | No | Request timeout in milliseconds |
|
||||
| `location` | json | No | Geographic context for proxying \(country, languages\) |
|
||||
| `apiKey` | string | Yes | Firecrawl API key |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -187,6 +198,9 @@ Extract structured data from entire webpages using natural language prompts and
|
||||
| `ignoreInvalidURLs` | boolean | No | Skip invalid URLs in the array \(default: true\) |
|
||||
| `scrapeOptions` | json | No | Advanced scraping configuration options |
|
||||
| `apiKey` | string | Yes | Firecrawl API key |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -217,7 +231,6 @@ Autonomous web data extraction agent. Searches and gathers information based on
|
||||
| `success` | boolean | Whether the agent operation was successful |
|
||||
| `status` | string | Current status of the agent job \(processing, completed, failed\) |
|
||||
| `data` | object | Extracted data from the agent |
|
||||
| `creditsUsed` | number | Number of credits consumed by this agent task |
|
||||
| `expiresAt` | string | Timestamp when the results expire \(24 hours\) |
|
||||
| `sources` | object | Array of source URLs used by the agent |
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ Search for books using the Google Books API
|
||||
| `startIndex` | number | No | Index of the first result to return \(for pagination\) |
|
||||
| `maxResults` | number | No | Maximum number of results to return \(1-40\) |
|
||||
| `langRestrict` | string | No | Restrict results to a specific language \(ISO 639-1 code\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -82,6 +84,8 @@ Get detailed information about a specific book volume
|
||||
| `apiKey` | string | Yes | Google Books API key |
|
||||
| `volumeId` | string | Yes | The ID of the volume to retrieve |
|
||||
| `projection` | string | No | Projection level \(full, lite\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ Get current air quality data for a location
|
||||
| `lat` | number | Yes | Latitude coordinate |
|
||||
| `lng` | number | Yes | Longitude coordinate |
|
||||
| `languageCode` | string | No | Language code for the response \(e.g., "en", "es"\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -91,6 +93,8 @@ Get directions and route information between two locations
|
||||
| `waypoints` | json | No | Array of intermediate waypoints |
|
||||
| `units` | string | No | Unit system: metric or imperial |
|
||||
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -135,6 +139,8 @@ Calculate travel distance and time between multiple origins and destinations
|
||||
| `avoid` | string | No | Features to avoid: tolls, highways, or ferries |
|
||||
| `units` | string | No | Unit system: metric or imperial |
|
||||
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -163,6 +169,8 @@ Get elevation data for a location
|
||||
| `apiKey` | string | Yes | Google Maps API key |
|
||||
| `lat` | number | Yes | Latitude coordinate |
|
||||
| `lng` | number | Yes | Longitude coordinate |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -185,6 +193,8 @@ Convert an address into geographic coordinates (latitude and longitude)
|
||||
| `address` | string | Yes | The address to geocode |
|
||||
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
|
||||
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -217,6 +227,8 @@ Geolocate a device using WiFi access points, cell towers, or IP address
|
||||
| `considerIp` | boolean | No | Whether to use IP address for geolocation \(default: true\) |
|
||||
| `cellTowers` | array | No | Array of cell tower objects with cellId, locationAreaCode, mobileCountryCode, mobileNetworkCode |
|
||||
| `wifiAccessPoints` | array | No | Array of WiFi access point objects with macAddress \(required\), signalStrength, etc. |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -238,6 +250,8 @@ Get detailed information about a specific place
|
||||
| `placeId` | string | Yes | Google Place ID |
|
||||
| `fields` | string | No | Comma-separated list of fields to return |
|
||||
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -290,6 +304,8 @@ Search for places using a text query
|
||||
| `type` | string | No | Place type filter \(e.g., restaurant, cafe, hotel\) |
|
||||
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
|
||||
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -322,6 +338,8 @@ Convert geographic coordinates (latitude and longitude) into a human-readable ad
|
||||
| `lat` | number | Yes | Latitude coordinate |
|
||||
| `lng` | number | Yes | Longitude coordinate |
|
||||
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -346,6 +364,8 @@ Snap GPS coordinates to the nearest road segment
|
||||
| `apiKey` | string | Yes | Google Maps API key with Roads API enabled |
|
||||
| `path` | string | Yes | Pipe-separated list of lat,lng coordinates \(e.g., "60.170880,24.942795\|60.170879,24.942796"\) |
|
||||
| `interpolate` | boolean | No | Whether to interpolate additional points along the road |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -399,6 +419,8 @@ Get timezone information for a location
|
||||
| `lng` | number | Yes | Longitude coordinate |
|
||||
| `timestamp` | number | No | Unix timestamp to determine DST offset \(defaults to current time\) |
|
||||
| `language` | string | No | Language code for timezone name \(e.g., en, es, fr\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -424,6 +446,8 @@ Validate and standardize a postal address
|
||||
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code \(e.g., "US", "CA"\) |
|
||||
| `locality` | string | No | City or locality name |
|
||||
| `enableUspsCass` | boolean | No | Enable USPS CASS validation for US addresses |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ Analyze a webpage for performance, accessibility, SEO, and best practices using
|
||||
| `category` | string | No | Lighthouse categories to analyze \(comma-separated\): performance, accessibility, best-practices, seo |
|
||||
| `strategy` | string | No | Analysis strategy: desktop or mobile |
|
||||
| `locale` | string | No | Locale for results \(e.g., en, fr, de\) |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ Translate text between languages using the Google Cloud Translation API. Support
|
||||
| `target` | string | Yes | Target language code \(e.g., "es", "fr", "de", "ja"\) |
|
||||
| `source` | string | No | Source language code. If omitted, the API will auto-detect the source language. |
|
||||
| `format` | string | No | Format of the text: "text" for plain text, "html" for HTML content |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -61,6 +64,9 @@ Detect the language of text using the Google Cloud Translation API.
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
|
||||
| `text` | string | Yes | The text to detect the language of |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -138,6 +138,26 @@ Get the full transcript of a recording
|
||||
| ↳ `end` | number | End timestamp in ms |
|
||||
| ↳ `text` | string | Transcript text |
|
||||
|
||||
### `grain_list_views`
|
||||
|
||||
List available Grain views for webhook subscriptions
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
|
||||
| `typeFilter` | string | No | Optional view type filter: recordings, highlights, or stories |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `views` | array | Array of Grain views |
|
||||
| ↳ `id` | string | View UUID |
|
||||
| ↳ `name` | string | View name |
|
||||
| ↳ `type` | string | View type: recordings, highlights, or stories |
|
||||
|
||||
### `grain_list_teams`
|
||||
|
||||
List all teams in the workspace
|
||||
@@ -185,15 +205,9 @@ Create a webhook to receive recording events
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
|
||||
| `hookUrl` | string | Yes | Webhook endpoint URL \(e.g., "https://example.com/webhooks/grain"\) |
|
||||
| `hookType` | string | Yes | Type of webhook: "recording_added" or "upload_status" |
|
||||
| `filterBeforeDatetime` | string | No | Filter: recordings before this ISO8601 date \(e.g., "2024-01-15T00:00:00Z"\) |
|
||||
| `filterAfterDatetime` | string | No | Filter: recordings after this ISO8601 date \(e.g., "2024-01-01T00:00:00Z"\) |
|
||||
| `filterParticipantScope` | string | No | Filter: "internal" or "external" |
|
||||
| `filterTeamId` | string | No | Filter: specific team UUID \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) |
|
||||
| `filterMeetingTypeId` | string | No | Filter: specific meeting type UUID \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) |
|
||||
| `includeHighlights` | boolean | No | Include highlights in webhook payload |
|
||||
| `includeParticipants` | boolean | No | Include participants in webhook payload |
|
||||
| `includeAiSummary` | boolean | No | Include AI summary in webhook payload |
|
||||
| `viewId` | string | Yes | Grain view ID from GET /_/public-api/views |
|
||||
| `actions` | array | No | Optional list of actions to subscribe to: added, updated, removed |
|
||||
| `items` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -202,9 +216,8 @@ Create a webhook to receive recording events
|
||||
| `id` | string | Hook UUID |
|
||||
| `enabled` | boolean | Whether hook is active |
|
||||
| `hook_url` | string | The webhook URL |
|
||||
| `hook_type` | string | Type of hook: recording_added or upload_status |
|
||||
| `filter` | object | Applied filters |
|
||||
| `include` | object | Included fields |
|
||||
| `view_id` | string | Grain view ID for the webhook |
|
||||
| `actions` | array | Configured actions for the webhook |
|
||||
| `inserted_at` | string | ISO8601 creation timestamp |
|
||||
|
||||
### `grain_list_hooks`
|
||||
@@ -225,9 +238,8 @@ List all webhooks for the account
|
||||
| ↳ `id` | string | Hook UUID |
|
||||
| ↳ `enabled` | boolean | Whether hook is active |
|
||||
| ↳ `hook_url` | string | Webhook URL |
|
||||
| ↳ `hook_type` | string | Type: recording_added or upload_status |
|
||||
| ↳ `filter` | object | Applied filters |
|
||||
| ↳ `include` | object | Included fields |
|
||||
| ↳ `view_id` | string | Grain view ID |
|
||||
| ↳ `actions` | array | Configured actions |
|
||||
| ↳ `inserted_at` | string | Creation timestamp |
|
||||
|
||||
### `grain_delete_hook`
|
||||
|
||||
@@ -64,6 +64,7 @@ Extract and process web content into clean, LLM-friendly text using Jina AI Read
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | The extracted content from the URL, processed into clean, LLM-friendly text |
|
||||
| `tokensUsed` | number | Number of Jina tokens consumed by this request |
|
||||
|
||||
### `jina_search`
|
||||
|
||||
@@ -97,5 +98,6 @@ Search the web and return top 5 results with LLM-friendly content. Each result i
|
||||
| ↳ `content` | string | LLM-friendly extracted content |
|
||||
| ↳ `usage` | object | Token usage information |
|
||||
| ↳ `tokens` | number | Number of tokens consumed by this request |
|
||||
| `tokensUsed` | number | Number of Jina tokens consumed by this request |
|
||||
|
||||
|
||||
|
||||
@@ -122,6 +122,37 @@ Create a new document in a knowledge base
|
||||
| `message` | string | Success or error message describing the operation result |
|
||||
| `documentId` | string | ID of the created document |
|
||||
|
||||
### `knowledge_upsert_document`
|
||||
|
||||
Create or update a document in a knowledge base. If a document with the given ID or filename already exists, it will be replaced with the new content.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
|
||||
| `documentId` | string | No | Optional ID of an existing document to update. If not provided, lookup is done by filename. |
|
||||
| `name` | string | Yes | Name of the document |
|
||||
| `content` | string | Yes | Content of the document |
|
||||
| `documentTags` | json | No | Document tags |
|
||||
| `documentTags` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `data` | object | Information about the upserted document |
|
||||
| ↳ `documentId` | string | Document ID |
|
||||
| ↳ `documentName` | string | Document name |
|
||||
| ↳ `type` | string | Document type |
|
||||
| ↳ `enabled` | boolean | Whether the document is enabled |
|
||||
| ↳ `isUpdate` | boolean | Whether an existing document was replaced |
|
||||
| ↳ `previousDocumentId` | string | ID of the document that was replaced, if any |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
| `message` | string | Success or error message describing the operation result |
|
||||
| `documentId` | string | ID of the upserted document |
|
||||
|
||||
### `knowledge_list_tags`
|
||||
|
||||
List all tag definitions for a knowledge base
|
||||
|
||||
@@ -51,6 +51,9 @@ Search the web for information using Linkup
|
||||
| `includeDomains` | string | No | Comma-separated list of domain names to restrict search results to |
|
||||
| `includeInlineCitations` | boolean | No | Add inline citations to answers \(only applies when outputType is "sourcedAnswer"\) |
|
||||
| `includeSources` | boolean | No | Include sources in response |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"asana",
|
||||
"ashby",
|
||||
"attio",
|
||||
"box",
|
||||
"brandfetch",
|
||||
"browser_use",
|
||||
"calcom",
|
||||
@@ -27,6 +28,7 @@
|
||||
"datadog",
|
||||
"devin",
|
||||
"discord",
|
||||
"docusign",
|
||||
"dropbox",
|
||||
"dspy",
|
||||
"dub",
|
||||
|
||||
@@ -49,6 +49,9 @@ Generate completions using Perplexity AI chat models
|
||||
| `max_tokens` | number | No | Maximum number of tokens to generate \(e.g., 1024, 2048, 4096\) |
|
||||
| `temperature` | number | No | Sampling temperature between 0 and 1 \(e.g., 0.0 for deterministic, 0.7 for creative\) |
|
||||
| `apiKey` | string | Yes | Perplexity API key |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -78,6 +81,8 @@ Get ranked search results from Perplexity
|
||||
| `search_after_date` | string | No | Include only content published after this date \(format: MM/DD/YYYY\) |
|
||||
| `search_before_date` | string | No | Include only content published before this date \(format: MM/DD/YYYY\) |
|
||||
| `apiKey` | string | Yes | Perplexity API key |
|
||||
| `pricing` | per_request | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ A powerful web search tool that provides access to Google search results through
|
||||
| `hl` | string | No | Language code for search results \(e.g., "en", "es", "de", "fr"\) |
|
||||
| `type` | string | No | Type of search to perform \(e.g., "search", "news", "images", "videos", "places", "shopping"\) |
|
||||
| `apiKey` | string | Yes | Serper API Key |
|
||||
| `pricing` | custom | No | No description |
|
||||
| `metadata` | string | No | No description |
|
||||
| `rateLimit` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"name": "Sim Documentation — Build AI Agents & Run Your Agentic Workforce",
|
||||
"short_name": "Sim Docs",
|
||||
"description": "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.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"src": "/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"src": "/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"theme_color": "#33C482",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
"display": "standalone",
|
||||
"categories": ["productivity", "developer", "business"],
|
||||
"lang": "en-US",
|
||||
"dir": "ltr"
|
||||
}
|
||||
|
||||
14
apps/docs/public/icon.svg
Normal file
14
apps/docs/public/icon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="#0B0B0B"/>
|
||||
<g transform="translate(2.75,2.75) scale(0.0473)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.822 93.7612C107.822 97.3481 106.403 100.792 103.884 103.328L103.523 103.692C101.006 106.236 97.5855 107.658 94.0236 107.658H13.4455C6.02456 107.658 0 113.718 0 121.191V208.332C0 215.806 6.02456 221.866 13.4455 221.866H99.9622C107.383 221.866 113.4 215.806 113.4 208.332V126.745C113.4 123.419 114.71 120.228 117.047 117.874C119.377 115.527 122.546 114.207 125.849 114.207H207.777C215.198 114.207 221.214 108.148 221.214 100.674V13.5333C221.214 6.05956 215.198 0 207.777 0H121.26C113.839 0 107.822 6.05956 107.822 13.5333V93.7612ZM134.078 18.55H194.952C199.289 18.55 202.796 22.0893 202.796 26.4503V87.7574C202.796 92.1178 199.289 95.6577 194.952 95.6577H134.078C129.748 95.6577 126.233 92.1178 126.233 87.7574V26.4503C126.233 22.0893 129.748 18.55 134.078 18.55Z" fill="#33C482"/>
|
||||
<path d="M207.878 129.57H143.554C135.756 129.57 129.434 135.937 129.434 143.791V207.784C129.434 215.638 135.756 222.005 143.554 222.005H207.878C215.677 222.005 221.999 215.638 221.999 207.784V143.791C221.999 135.937 215.677 129.57 207.878 129.57Z" fill="#33C482"/>
|
||||
<path d="M207.878 129.266H143.554C135.756 129.266 129.434 135.632 129.434 143.487V207.479C129.434 215.333 135.756 221.699 143.554 221.699H207.878C215.677 221.699 221.999 215.333 221.999 207.479V143.487C221.999 135.632 215.677 129.266 207.878 129.266Z" fill="url(#paint0_linear)" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="129.434" y1="129.266" x2="185.629" y2="185.33" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/docs/public/static/tables/tables-overview.png
Normal file
BIN
apps/docs/public/static/tables/tables-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 591 KiB |
@@ -55,7 +55,7 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
'group inline-flex h-[30px] items-center justify-center gap-[7px] rounded-[5px] border px-[9px] text-[13.5px] transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'group inline-flex h-[32px] items-center justify-center gap-[8px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px] transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!hasCustomColor &&
|
||||
'border-[#FFFFFF] bg-[#FFFFFF] text-black hover:border-[#E0E0E0] hover:bg-[#E0E0E0]',
|
||||
fullWidth && 'w-full',
|
||||
|
||||
@@ -28,8 +28,12 @@ export function StatusPageLayout({
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>{title}</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>{description}</p>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
{title}
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{children && <div className='mt-8 w-full max-w-[410px] space-y-3'>{children}</div>}
|
||||
</div>
|
||||
|
||||
@@ -383,8 +383,12 @@ export default function LoginPage({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>Sign in</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>Enter your details</p>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Sign in
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
Enter your details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SSO Login Button (primary top-only when it is the only method) */}
|
||||
|
||||
@@ -127,10 +127,12 @@ export default function OAuthConsentPage() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>Loading application details...</p>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
Loading application details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -140,10 +142,12 @@ export default function OAuthConsentPage() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Authorization Error
|
||||
</h1>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>{error}</p>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-8 w-full max-w-[410px] space-y-3'>
|
||||
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
@@ -181,10 +185,10 @@ export default function OAuthConsentPage() {
|
||||
</div>
|
||||
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Authorize Application
|
||||
</h1>
|
||||
<p className={'font-[380] text-[#999] text-[16px]'}>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<span className='font-medium text-[#ECECEC]'>{clientName}</span> is requesting access to
|
||||
your account
|
||||
</p>
|
||||
|
||||
@@ -74,10 +74,12 @@ function ResetPasswordContent() {
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>Enter a new password for your account</p>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-8'>
|
||||
|
||||
@@ -341,8 +341,12 @@ function SignupFormContent({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>Create an account</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>Create an account or log in</p>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Create an account
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
Create an account or log in
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SSO Login Button (primary top-only when it is the only method) */}
|
||||
|
||||
@@ -59,10 +59,10 @@ function VerificationForm({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
|
||||
</h1>
|
||||
<p className='font-[380] text-[#999] text-[16px]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
{isVerified
|
||||
? 'Your email has been verified. Redirecting to dashboard...'
|
||||
: !isEmailVerificationEnabled
|
||||
|
||||
@@ -222,34 +222,15 @@ export default function Collaboration() {
|
||||
<style dangerouslySetInnerHTML={{ __html: CURSOR_KEYFRAMES }} />
|
||||
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
className='overflow-hidden 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]'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-[auto_1fr]'>
|
||||
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px] md:pt-[100px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -268,8 +249,9 @@ export default function Collaboration() {
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
Grab your team. Build agents together <br /> in real-time inside your workspace.
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] md:text-[18px]'>
|
||||
Grab your team. Build agents together <br className='hidden md:block' />
|
||||
in real-time inside your workspace.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
@@ -298,14 +280,14 @@ export default function Collaboration() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<figure className='pointer-events-none relative h-[600px] w-full'>
|
||||
<div className='-left-[18%] absolute inset-y-0 min-w-full'>
|
||||
<figure className='pointer-events-none relative h-[220px] w-full md:h-[600px]'>
|
||||
<div className='md:-left-[18%] -top-[10%] absolute inset-y-0 left-[7%] min-w-full md:top-0'>
|
||||
<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'
|
||||
className='h-full w-auto object-left md:min-w-[100vw]'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
@@ -319,10 +301,29 @@ export default function Collaboration() {
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href='/studio/multiplayer'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='relative mx-4 mb-6 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:mx-8 md:absolute md:bottom-10 md:left-[80px] md:z-20 md:mx-0 md:mb-0'
|
||||
>
|
||||
<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>
|
||||
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
|
||||
@@ -4,14 +4,484 @@
|
||||
* SEO:
|
||||
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
|
||||
* - `<h2 id="enterprise-heading">` for the section title.
|
||||
* - Compliance certs (SOC2, HIPAA) as visible `<strong>` text.
|
||||
* - Compliance certs (SOC 2, 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."
|
||||
* - Entity-rich: "Sim is SOC 2 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
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { Lock } from '@/components/emcn/icons'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
|
||||
/** Consistent color per actor — same pattern as Collaboration section cursors. */
|
||||
const ACTOR_COLORS: Record<string, string> = {
|
||||
'Sarah K.': '#2ABBF8',
|
||||
'Sid G.': '#33C482',
|
||||
'Theo L.': '#FA4EDF',
|
||||
'Abhay K.': '#FFCC02',
|
||||
'Danny S.': '#FF6B35',
|
||||
}
|
||||
|
||||
/** Left accent bar opacity by recency — newest is brightest. */
|
||||
const ACCENT_OPACITIES = [0.75, 0.45, 0.28, 0.15, 0.07] as const
|
||||
|
||||
/** Human-readable label per resource type. */
|
||||
const RESOURCE_TYPE_LABEL: Record<string, string> = {
|
||||
workflow: 'Workflow',
|
||||
member: 'Member',
|
||||
byok_key: 'BYOK Key',
|
||||
api_key: 'API Key',
|
||||
permission_group: 'Permission Group',
|
||||
credential_set: 'Credential Set',
|
||||
knowledge_base: 'Knowledge Base',
|
||||
environment: 'Environment',
|
||||
mcp_server: 'MCP Server',
|
||||
file: 'File',
|
||||
webhook: 'Webhook',
|
||||
chat: 'Chat',
|
||||
table: 'Table',
|
||||
folder: 'Folder',
|
||||
document: 'Document',
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number
|
||||
actor: string
|
||||
/** Matches the `description` field stored by recordAudit() */
|
||||
description: string
|
||||
resourceType: string
|
||||
/** Unix ms timestamp of when this entry was "received" */
|
||||
insertedAt: number
|
||||
}
|
||||
|
||||
function formatTimeAgo(insertedAt: number): string {
|
||||
const elapsed = Date.now() - insertedAt
|
||||
if (elapsed < 8_000) return 'just now'
|
||||
if (elapsed < 60_000) return `${Math.floor(elapsed / 1000)}s ago`
|
||||
return `${Math.floor(elapsed / 60_000)}m ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry templates using real description strings from the actual recordAudit()
|
||||
* calls across the codebase (e.g. `Added BYOK key for openai`,
|
||||
* `Invited alex@acme.com to workspace as member`).
|
||||
*/
|
||||
const ENTRY_TEMPLATES: Omit<LogEntry, 'id' | 'insertedAt'>[] = [
|
||||
{ actor: 'Sarah K.', description: 'Deployed workflow "Email Triage"', resourceType: 'workflow' },
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Invited alex@acme.com to workspace as member',
|
||||
resourceType: 'member',
|
||||
},
|
||||
{ actor: 'Theo L.', description: 'Added BYOK key for openai', resourceType: 'byok_key' },
|
||||
{ actor: 'Sarah K.', description: 'Created workflow "Invoice Parser"', resourceType: 'workflow' },
|
||||
{
|
||||
actor: 'Abhay K.',
|
||||
description: 'Created permission group "Engineering"',
|
||||
resourceType: 'permission_group',
|
||||
},
|
||||
{ actor: 'Danny S.', description: 'Created API key "Production Key"', resourceType: 'api_key' },
|
||||
{
|
||||
actor: 'Theo L.',
|
||||
description: 'Changed permissions for sam@acme.com to editor',
|
||||
resourceType: 'member',
|
||||
},
|
||||
{ actor: 'Sarah K.', description: 'Uploaded file "Q3_Report.pdf"', resourceType: 'file' },
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Created credential set "Prod Keys"',
|
||||
resourceType: 'credential_set',
|
||||
},
|
||||
{
|
||||
actor: 'Abhay K.',
|
||||
description: 'Created knowledge base "Internal Docs"',
|
||||
resourceType: 'knowledge_base',
|
||||
},
|
||||
{ actor: 'Danny S.', description: 'Updated environment variables', resourceType: 'environment' },
|
||||
{
|
||||
actor: 'Sarah K.',
|
||||
description: 'Added tool "search_web" to MCP server',
|
||||
resourceType: 'mcp_server',
|
||||
},
|
||||
{ actor: 'Sid G.', description: 'Created webhook "Stripe Payment"', resourceType: 'webhook' },
|
||||
{ actor: 'Theo L.', description: 'Deployed chat "Support Assistant"', resourceType: 'chat' },
|
||||
{ actor: 'Abhay K.', description: 'Created table "Lead Tracker"', resourceType: 'table' },
|
||||
{ actor: 'Danny S.', description: 'Revoked API key "Staging Key"', resourceType: 'api_key' },
|
||||
{
|
||||
actor: 'Sarah K.',
|
||||
description: 'Duplicated workflow "Data Enrichment"',
|
||||
resourceType: 'workflow',
|
||||
},
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Removed member theo@acme.com from workspace',
|
||||
resourceType: 'member',
|
||||
},
|
||||
{
|
||||
actor: 'Theo L.',
|
||||
description: 'Updated knowledge base "Product Docs"',
|
||||
resourceType: 'knowledge_base',
|
||||
},
|
||||
{ actor: 'Abhay K.', description: 'Created folder "Finance Workflows"', resourceType: 'folder' },
|
||||
{
|
||||
actor: 'Danny S.',
|
||||
description: 'Uploaded document "onboarding-guide.pdf"',
|
||||
resourceType: 'document',
|
||||
},
|
||||
{
|
||||
actor: 'Sarah K.',
|
||||
description: 'Updated credential set "Prod Keys"',
|
||||
resourceType: 'credential_set',
|
||||
},
|
||||
{
|
||||
actor: 'Sid G.',
|
||||
description: 'Added member abhay@acme.com to permission group "Engineering"',
|
||||
resourceType: 'permission_group',
|
||||
},
|
||||
{ actor: 'Theo L.', description: 'Locked workflow "Customer Sync"', resourceType: 'workflow' },
|
||||
]
|
||||
|
||||
const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 240_000, 540_000]
|
||||
|
||||
const MARQUEE_KEYFRAMES = `
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-25%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes marquee { 0%, 100% { transform: none; } }
|
||||
}
|
||||
`
|
||||
|
||||
const FEATURE_TAGS = [
|
||||
'Access Control',
|
||||
'Self-Hosting',
|
||||
'Bring Your Own Key',
|
||||
'Credential Sharing',
|
||||
'Custom Limits',
|
||||
'Admin API',
|
||||
'White Labeling',
|
||||
'Dedicated Support',
|
||||
'99.99% Uptime SLA',
|
||||
'Workflow Versioning',
|
||||
'On-Premise',
|
||||
'Organizations',
|
||||
'Workspace Export',
|
||||
'Audit Logs',
|
||||
] as const
|
||||
|
||||
interface AuditRowProps {
|
||||
entry: LogEntry
|
||||
index: number
|
||||
}
|
||||
|
||||
function AuditRow({ entry, index }: AuditRowProps) {
|
||||
const color = ACTOR_COLORS[entry.actor] ?? '#F6F6F6'
|
||||
const accentOpacity = ACCENT_OPACITIES[index] ?? 0.04
|
||||
const timeAgo = formatTimeAgo(entry.insertedAt)
|
||||
const resourceLabel = RESOURCE_TYPE_LABEL[entry.resourceType]
|
||||
|
||||
return (
|
||||
<div className='group relative overflow-hidden border-[#2A2A2A] border-b bg-[#191919] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
|
||||
{/* Left accent bar — brightness encodes recency */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-0 w-[2px] transition-opacity duration-150 group-hover:opacity-100'
|
||||
style={{ backgroundColor: color, opacity: accentOpacity }}
|
||||
/>
|
||||
|
||||
{/* Row content */}
|
||||
<div className='flex min-w-0 items-center gap-3 py-[10px] pr-4 pl-5'>
|
||||
{/* Actor avatar */}
|
||||
<div
|
||||
className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full'
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<span className='font-[500] font-season text-[9px] leading-none' style={{ color }}>
|
||||
{entry.actor[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
|
||||
{timeAgo}
|
||||
</span>
|
||||
|
||||
{/* Description — description hidden on mobile to avoid truncation */}
|
||||
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
|
||||
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
|
||||
<span className='hidden sm:inline'>
|
||||
<span className='text-[#F6F6F6]/40'> · </span>
|
||||
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Resource type label — formatted name, neutral so it doesn't compete with actor colors */}
|
||||
{resourceLabel && (
|
||||
<span className='ml-auto shrink-0 rounded border border-[#2A2A2A] px-[7px] py-[3px] font-[430] font-season text-[#F6F6F6]/25 text-[10px] leading-none tracking-[0.04em]'>
|
||||
{resourceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditLogPreview() {
|
||||
const counterRef = useRef(ENTRY_TEMPLATES.length)
|
||||
const templateIndexRef = useRef(5 % ENTRY_TEMPLATES.length)
|
||||
|
||||
const now = Date.now()
|
||||
const [entries, setEntries] = useState<LogEntry[]>(() =>
|
||||
ENTRY_TEMPLATES.slice(0, 5).map((t, i) => ({
|
||||
...t,
|
||||
id: i,
|
||||
insertedAt: now - INITIAL_OFFSETS_MS[i],
|
||||
}))
|
||||
)
|
||||
const [, tick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const addInterval = setInterval(() => {
|
||||
const template = ENTRY_TEMPLATES[templateIndexRef.current]
|
||||
templateIndexRef.current = (templateIndexRef.current + 1) % ENTRY_TEMPLATES.length
|
||||
|
||||
setEntries((prev) => [
|
||||
{ ...template, id: counterRef.current++, insertedAt: Date.now() },
|
||||
...prev.slice(0, 4),
|
||||
])
|
||||
}, 2600)
|
||||
|
||||
// Refresh time labels every 5s so "just now" ages to "Xs ago"
|
||||
const tickInterval = setInterval(() => tick((n) => n + 1), 5_000)
|
||||
|
||||
return () => {
|
||||
clearInterval(addInterval)
|
||||
clearInterval(tickInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mx-6 mt-6 overflow-hidden rounded-[8px] border border-[#2A2A2A] md:mx-8 md:mt-8'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between border-[#2A2A2A] border-b bg-[#161616] px-4 py-[10px]'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Pulsing live indicator */}
|
||||
<span className='relative flex h-[8px] w-[8px]'>
|
||||
<span
|
||||
className='absolute inline-flex h-full w-full animate-ping rounded-full opacity-50'
|
||||
style={{ backgroundColor: '#33C482' }}
|
||||
/>
|
||||
<span
|
||||
className='relative inline-flex h-[8px] w-[8px] rounded-full'
|
||||
style={{ backgroundColor: '#33C482' }}
|
||||
/>
|
||||
</span>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/40 text-[11px] uppercase tracking-[0.08em]'>
|
||||
Audit Log
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'>
|
||||
Export
|
||||
</span>
|
||||
<span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'>
|
||||
Filter
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries — new items push existing ones down */}
|
||||
<div className='overflow-hidden'>
|
||||
<AnimatePresence mode='popLayout' initial={false}>
|
||||
{entries.map((entry, index) => (
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
layout
|
||||
initial={{ y: -48, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
stiffness: 380,
|
||||
damping: 38,
|
||||
mass: 0.8,
|
||||
},
|
||||
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
opacity: { duration: 0.25 },
|
||||
}}
|
||||
>
|
||||
<AuditRow entry={entry} index={index} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrustStrip() {
|
||||
return (
|
||||
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-[8px] border border-[#2A2A2A] sm:grid-cols-3 md:mx-8'>
|
||||
{/* SOC 2 + HIPAA combined */}
|
||||
<Link
|
||||
href='https://trust.delve.co/sim-studio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
>
|
||||
<Image
|
||||
src='/footer/soc2.png'
|
||||
alt='SOC 2 Type II'
|
||||
width={22}
|
||||
height={22}
|
||||
className='shrink-0 object-contain'
|
||||
/>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
|
||||
SOC 2 & HIPAA
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
|
||||
Type II · PHI protected →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Open Source — center */}
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
>
|
||||
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#FFCC02]/10'>
|
||||
<GithubIcon width={11} height={11} className='text-[#FFCC02]/75' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
|
||||
Open Source
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
|
||||
View on GitHub →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* SSO */}
|
||||
<div className='flex items-center gap-3 px-4 py-[14px]'>
|
||||
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#2ABBF8]/10'>
|
||||
<Lock className='h-[14px] w-[14px] text-[#2ABBF8]/75' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
|
||||
SSO & SCIM
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
|
||||
Okta, Azure AD, Google
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Enterprise() {
|
||||
return (
|
||||
<section id='enterprise' aria-labelledby='enterprise-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#FFCC02]/10 font-season text-[#FFCC02] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Enterprise
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='enterprise-heading'
|
||||
className='max-w-[600px] font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Enterprise features for
|
||||
<br />
|
||||
fast, scalable workflows
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 overflow-hidden rounded-[12px] bg-[#1C1C1C] sm:mt-10 md:mt-12'>
|
||||
<AuditLogPreview />
|
||||
<TrustStrip />
|
||||
|
||||
{/* Scrolling feature ticker */}
|
||||
<div className='relative mt-6 overflow-hidden border-[#2A2A2A] border-t'>
|
||||
<style dangerouslySetInnerHTML={{ __html: MARQUEE_KEYFRAMES }} />
|
||||
{/* Fade edges */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-16'
|
||||
style={{ background: 'linear-gradient(to right, #1C1C1C, transparent)' }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-16'
|
||||
style={{ background: 'linear-gradient(to left, #1C1C1C, transparent)' }}
|
||||
/>
|
||||
{/* Duplicate tags for seamless loop */}
|
||||
<div className='flex w-max' style={{ animation: 'marquee 30s linear infinite' }}>
|
||||
{[...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS].map(
|
||||
(tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className='whitespace-nowrap border-[#2A2A2A] border-r px-5 py-4 font-[430] font-season text-[#F6F6F6]/40 text-[13px] leading-none tracking-[0.02em]'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-[#2A2A2A] border-t px-6 py-5 md:px-8 md:py-6'>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/40 text-[15px] leading-[150%] tracking-[0.02em]'>
|
||||
Ready for growth?
|
||||
</p>
|
||||
<Link
|
||||
href='/contact'
|
||||
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-white bg-white px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Book a demo
|
||||
<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'
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@ const FEATURE_TABS = [
|
||||
},
|
||||
{
|
||||
label: 'Knowledge Base',
|
||||
mobileLabel: 'Knowledge',
|
||||
color: '#8B5CF6',
|
||||
title: 'Your context engine',
|
||||
description:
|
||||
@@ -97,6 +98,7 @@ const FEATURE_TABS = [
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
hideOnMobile: true,
|
||||
color: '#FF6B35',
|
||||
title: 'Full visibility, every run',
|
||||
description:
|
||||
@@ -150,7 +152,7 @@ function DotGrid({
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={`shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
className={`h-full shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
display: 'grid',
|
||||
@@ -192,8 +194,11 @@ export default function Features() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 pt-[100px]'>
|
||||
<div ref={sectionRef} className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<div className='relative z-10 pt-[60px] lg:pt-[100px]'>
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className='flex flex-col items-start gap-[20px] px-[24px] lg:px-[80px]'
|
||||
>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -211,7 +216,7 @@ export default function Features() {
|
||||
</Badge>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[40px] leading-[110%] tracking-[-0.02em]'
|
||||
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[28px] leading-[110%] tracking-[-0.02em] md:text-[40px]'
|
||||
>
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
@@ -225,18 +230,25 @@ export default function Features() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-[73px] pb-[80px]'>
|
||||
<div className='relative mt-[40px] pb-[40px] lg:mt-[73px] lg:pb-[80px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 w-px bg-[#E9E9E9]'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 hidden w-px bg-[#E9E9E9] lg:block'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 w-px bg-[#E9E9E9]'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 hidden w-px bg-[#E9E9E9] lg:block'
|
||||
/>
|
||||
|
||||
<div className='flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
<div className='flex h-[68px] border border-[#E9E9E9] lg:overflow-hidden'>
|
||||
<div className='h-full shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid cols={3} rows={8} width={24} />
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
@@ -246,10 +258,17 @@ export default function Features() {
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
|
||||
className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-[12px] font-medium font-season text-[#212121] text-[12px] uppercase lg:px-0 lg:text-[14px]${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[#E9E9E9] border-l' : ''}`}
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.mobileLabel ? (
|
||||
<>
|
||||
<span className='lg:hidden'>{tab.mobileLabel}</span>
|
||||
<span className='hidden lg:inline'>{tab.label}</span>
|
||||
</>
|
||||
) : (
|
||||
tab.label
|
||||
)}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
@@ -269,16 +288,23 @@ export default function Features() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
<div className='h-full shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid cols={3} rows={8} width={24} />
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[60px] grid grid-cols-[1fr_2.8fr] gap-[60px] px-[120px]'>
|
||||
<div className='flex h-[560px] flex-col items-start justify-between pt-[20px]'>
|
||||
<div className='mt-[32px] flex flex-col gap-[24px] px-[24px] lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[120px]'>
|
||||
<div className='flex flex-col items-start justify-between gap-[24px] pt-[20px] lg:h-[560px] lg:gap-0'>
|
||||
<div className='flex flex-col items-start gap-[16px]'>
|
||||
<h3 className='font-[430] font-season text-[#1C1C1C] text-[28px] leading-[120%] tracking-[-0.02em]'>
|
||||
<h3 className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[120%] tracking-[-0.02em] lg:text-[28px]'>
|
||||
{FEATURE_TABS[activeTab].title}
|
||||
</h3>
|
||||
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[16px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
|
||||
{FEATURE_TABS[activeTab].description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -307,10 +333,10 @@ export default function Features() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FeaturesPreview />
|
||||
<FeaturesPreview activeTab={activeTab} />
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[60px] h-px bg-[#E9E9E9]' />
|
||||
<div aria-hidden='true' className='mt-[60px] hidden h-px bg-[#E9E9E9] lg:block' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
100
apps/sim/app/(home)/components/footer/footer-cta.tsx
Normal file
100
apps/sim/app/(home)/components/footer/footer-cta.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
|
||||
|
||||
const MAX_HEIGHT = 120
|
||||
|
||||
const CTA_BUTTON =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||
|
||||
export function FooterCTA() {
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const animatedPlaceholder = useAnimatedPlaceholder()
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, MAX_HEIGHT)}px`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center px-4 pt-[120px] pb-[100px] sm:px-8 md:px-[80px]'>
|
||||
<h2 className='text-center font-[430] font-season text-[#1C1C1C] text-[28px] leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'>
|
||||
What should we get done?
|
||||
</h2>
|
||||
|
||||
<div className='mt-8 w-full max-w-[42rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border border-[#E5E5E5] bg-white px-[10px] py-[8px] shadow-sm'
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={2}
|
||||
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-[4px] py-[4px] font-body text-[#1C1C1C] text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#999] focus-visible:ring-0'
|
||||
style={{ caretColor: '#1C1C1C', maxHeight: `${MAX_HEIGHT}px` }}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#C0C0C0' : '#1C1C1C',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#FFFFFF' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex gap-[8px]'>
|
||||
<a
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`${CTA_BUTTON} border-[#D4D4D4] text-[#1C1C1C] transition-colors hover:bg-[#E8E8E8]`}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BUTTON} gap-[8px] border-[#1C1C1C] bg-[#1C1C1C] text-white transition-colors hover:border-[#333] hover:bg-[#333]`}
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,188 +1,165 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
|
||||
import { FooterCTA } from '@/app/(home)/components/footer/footer-cta'
|
||||
|
||||
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
|
||||
|
||||
interface FooterLink {
|
||||
interface FooterItem {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const FOOTER_LINKS: FooterLink[] = [
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
const PRODUCT_LINKS: FooterItem[] = [
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
{ label: 'Sim Studio', href: '/studio' },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
{ label: 'Self Hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
|
||||
{ label: 'MCP', href: 'https://docs.sim.ai/mcp', external: true },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'SOC2', href: 'https://trust.delve.co/sim-studio', external: true },
|
||||
{ label: 'Privacy Policy', href: '/privacy', external: true },
|
||||
{ label: 'Terms of Service', href: '/terms', external: true },
|
||||
]
|
||||
|
||||
export default function Footer() {
|
||||
const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
]
|
||||
|
||||
const BLOCK_LINKS: FooterItem[] = [
|
||||
{ label: 'Agent', href: 'https://docs.sim.ai/blocks/agent', external: true },
|
||||
{ label: 'Router', href: 'https://docs.sim.ai/blocks/router', external: true },
|
||||
{ label: 'Function', href: 'https://docs.sim.ai/blocks/function', external: true },
|
||||
{ label: 'Condition', href: 'https://docs.sim.ai/blocks/condition', external: true },
|
||||
{ label: 'API', href: 'https://docs.sim.ai/blocks/api', external: true },
|
||||
{ label: 'Workflow', href: 'https://docs.sim.ai/blocks/workflow', external: true },
|
||||
{ label: 'Parallel', href: 'https://docs.sim.ai/blocks/parallel', external: true },
|
||||
{ label: 'Guardrails', href: 'https://docs.sim.ai/blocks/guardrails', external: true },
|
||||
{ label: 'Evaluator', href: 'https://docs.sim.ai/blocks/evaluator', external: true },
|
||||
{ label: 'Loop', href: 'https://docs.sim.ai/blocks/loop', external: true },
|
||||
]
|
||||
|
||||
const INTEGRATION_LINKS: FooterItem[] = [
|
||||
{ label: 'Confluence', href: 'https://docs.sim.ai/tools/confluence', external: true },
|
||||
{ label: 'Slack', href: 'https://docs.sim.ai/tools/slack', external: true },
|
||||
{ label: 'GitHub', href: 'https://docs.sim.ai/tools/github', external: true },
|
||||
{ label: 'Gmail', href: 'https://docs.sim.ai/tools/gmail', external: true },
|
||||
{ label: 'HubSpot', href: 'https://docs.sim.ai/tools/hubspot', external: true },
|
||||
{ label: 'Salesforce', href: 'https://docs.sim.ai/tools/salesforce', external: true },
|
||||
{ label: 'Notion', href: 'https://docs.sim.ai/tools/notion', external: true },
|
||||
{ label: 'Google Drive', href: 'https://docs.sim.ai/tools/google_drive', external: true },
|
||||
{ label: 'Google Sheets', href: 'https://docs.sim.ai/tools/google_sheets', external: true },
|
||||
{ label: 'Supabase', href: 'https://docs.sim.ai/tools/supabase', external: true },
|
||||
{ label: 'Stripe', href: 'https://docs.sim.ai/tools/stripe', external: true },
|
||||
{ label: 'Jira', href: 'https://docs.sim.ai/tools/jira', external: true },
|
||||
{ label: 'Linear', href: 'https://docs.sim.ai/tools/linear', external: true },
|
||||
{ label: 'Airtable', href: 'https://docs.sim.ai/tools/airtable', external: true },
|
||||
{ label: 'Firecrawl', href: 'https://docs.sim.ai/tools/firecrawl', external: true },
|
||||
{ label: 'Pinecone', href: 'https://docs.sim.ai/tools/pinecone', external: true },
|
||||
{ label: 'Discord', href: 'https://docs.sim.ai/tools/discord', external: true },
|
||||
{ label: 'Microsoft Teams', href: 'https://docs.sim.ai/tools/microsoft_teams', external: true },
|
||||
{ label: 'Outlook', href: 'https://docs.sim.ai/tools/outlook', external: true },
|
||||
{ label: 'Telegram', href: 'https://docs.sim.ai/tools/telegram', external: true },
|
||||
]
|
||||
|
||||
const SOCIAL_LINKS: FooterItem[] = [
|
||||
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
|
||||
{ label: 'LinkedIn', href: 'https://www.linkedin.com/company/simstudioai/', external: true },
|
||||
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
|
||||
{ label: 'GitHub', href: 'https://github.com/simstudioai/sim', external: true },
|
||||
]
|
||||
|
||||
const LEGAL_LINKS: FooterItem[] = [
|
||||
{ label: 'Terms of Service', href: '/terms' },
|
||||
{ label: 'Privacy Policy', href: '/privacy' },
|
||||
]
|
||||
|
||||
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>{title}</h3>
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
{items.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
hideCTA?: boolean
|
||||
}
|
||||
|
||||
export default function Footer({ hideCTA }: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
role='contentinfo'
|
||||
className='relative w-full overflow-hidden bg-[#1C1C1C] font-[430] font-season text-[14px]'
|
||||
className={`bg-[#F6F6F6] pb-[40px] font-[430] font-season text-[14px]${hideCTA ? ' pt-[40px]' : ''}`}
|
||||
>
|
||||
<div className='px-4 pt-[80px] pb-[40px] sm:px-8 sm:pb-[340px] md:px-[80px]'>
|
||||
<nav aria-label='Footer navigation' className='flex justify-between'>
|
||||
{/* Brand column */}
|
||||
<div className='flex flex-col gap-[24px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Community column */}
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Community</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<a
|
||||
href='https://discord.gg/Hr4UWYEcTT'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href='https://x.com/simdotai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
<a
|
||||
href='https://www.linkedin.com/company/simstudioai/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
{!hideCTA && <FooterCTA />}
|
||||
<div className='px-4 sm:px-8 md:px-[80px]'>
|
||||
<div className='relative overflow-hidden rounded-lg bg-[#1C1C1C] px-6 pt-[40px] pb-[32px] sm:px-10 sm:pt-[48px] sm:pb-[40px]'>
|
||||
<nav
|
||||
aria-label='Footer navigation'
|
||||
className='relative z-[1] grid grid-cols-2 gap-x-8 gap-y-10 sm:grid-cols-3 lg:grid-cols-7'
|
||||
>
|
||||
<div className='col-span-2 flex flex-col gap-[24px] sm:col-span-1'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={85}
|
||||
height={26}
|
||||
className='h-[26.4px] w-auto'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links column */}
|
||||
<div>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>More Sim</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_LINKS.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FooterColumn title='Product' items={PRODUCT_LINKS} />
|
||||
<FooterColumn title='Resources' items={RESOURCES_LINKS} />
|
||||
<FooterColumn title='Blocks' items={BLOCK_LINKS} />
|
||||
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
|
||||
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
|
||||
<FooterColumn title='Legal' items={LEGAL_LINKS} />
|
||||
</nav>
|
||||
|
||||
{/* Blocks column */}
|
||||
<div className='hidden sm:block'>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Blocks</h3>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
<a
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{block}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools columns */}
|
||||
<div className='hidden sm:block'>
|
||||
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Tools</h3>
|
||||
<div className='flex gap-[80px]'>
|
||||
{[0, 1, 2, 3].map((quarter) => {
|
||||
const start = Math.ceil((FOOTER_TOOLS.length * quarter) / 4)
|
||||
const end =
|
||||
quarter === 3
|
||||
? FOOTER_TOOLS.length
|
||||
: Math.ceil((FOOTER_TOOLS.length * (quarter + 1)) / 4)
|
||||
return (
|
||||
<div key={quarter} className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_TOOLS.slice(start, end).map((tool) => (
|
||||
<a
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`whitespace-nowrap ${LINK_CLASS}`}
|
||||
>
|
||||
{tool}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Large SIM wordmark — half cut off */}
|
||||
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='1128'
|
||||
height='550'
|
||||
viewBox='0 0 1128 550'
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
|
||||
fill='#2A2A2A'
|
||||
/>
|
||||
<path
|
||||
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
|
||||
fill='#2A2A2A'
|
||||
/>
|
||||
<path
|
||||
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
|
||||
fill='#2A2A2A'
|
||||
/>
|
||||
<path
|
||||
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
|
||||
stroke='#3D3D3D'
|
||||
strokeWidth='1.28396'
|
||||
/>
|
||||
</svg>
|
||||
{/* <svg
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute bottom-0 left-[-60px] hidden w-[85%] sm:block'
|
||||
viewBox='0 0 1800 316'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M18.3562 305V48.95A30.594 30.594 0 0 1 48.95 18.356H917.05A30.594 30.594 0 0 1 947.644 48.95V273H1768C1777.11 273 1784.5 280.387 1784.5 289.5C1784.5 298.613 1777.11 306 1768 306H96.8603C78.635 306 63.8604 310 63.8604 305H18.3562'
|
||||
stroke='#2A2A2A'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<rect
|
||||
x='58'
|
||||
y='58'
|
||||
width='849.288'
|
||||
height='199.288'
|
||||
rx='14'
|
||||
stroke='#2A2A2A'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
</svg> */}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Hero() {
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[100px] pb-[12px]'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[60px] pb-[12px] lg:pt-[100px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
@@ -61,11 +61,11 @@ export default function Hero() {
|
||||
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='font-[430] font-season text-[72px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
className='font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
|
||||
>
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[15px] leading-[125%] tracking-[0.02em] lg:text-[18px]'>
|
||||
Sim is the AI Workspace for Agent Builders.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GmailIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleSheetsIcon,
|
||||
HubspotIcon,
|
||||
JiraIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
OpenAIIcon,
|
||||
RedditIcon,
|
||||
ReductoIcon,
|
||||
SalesforceIcon,
|
||||
ScheduleIcon,
|
||||
SlackIcon,
|
||||
StartIcon,
|
||||
@@ -57,11 +59,13 @@ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
gmail: GmailIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
hubspot: HubspotIcon,
|
||||
linear: LinearIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
reddit: RedditIcon,
|
||||
notion: NotionIcon,
|
||||
reducto: ReductoIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
textract: TextractIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
mothership: Blimp,
|
||||
|
||||
@@ -112,10 +112,14 @@ const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
},
|
||||
{
|
||||
id: 'mothership-1',
|
||||
name: 'Update Agent',
|
||||
name: 'CRM Agent',
|
||||
type: 'mothership',
|
||||
bgColor: '#33C482',
|
||||
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
|
||||
tools: [
|
||||
{ name: 'HubSpot', type: 'hubspot', bgColor: '#FF7A59' },
|
||||
{ name: 'Salesforce', type: 'salesforce', bgColor: '#E0E0E0' },
|
||||
],
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
|
||||
@@ -95,7 +95,7 @@ export function LandingPreview() {
|
||||
onSelectHome={handleSelectHome}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex min-w-0 flex-1 flex-col py-[8px] pr-[8px] pl-[8px] lg:pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const FEATURED_POST = {
|
||||
title: 'Build with Sim for Enterprise',
|
||||
slug: 'enterprise',
|
||||
image: '/blog/thumbnails/enterprise.webp',
|
||||
} as const
|
||||
|
||||
const POSTS = [
|
||||
{ title: 'Introducing Sim v0.5', slug: 'v0-5', image: '/blog/thumbnails/v0-5.webp' },
|
||||
{ title: '$7M Series A', slug: 'series-a', image: '/blog/thumbnails/series-a.webp' },
|
||||
{
|
||||
title: 'Realtime Collaboration',
|
||||
slug: 'multiplayer',
|
||||
image: '/blog/thumbnails/multiplayer.webp',
|
||||
},
|
||||
{ title: 'Inside the Executor', slug: 'executor', image: '/blog/thumbnails/executor.webp' },
|
||||
{ title: 'Inside Sim Copilot', slug: 'copilot', image: '/blog/thumbnails/copilot.webp' },
|
||||
] as const
|
||||
|
||||
function BlogCard({
|
||||
slug,
|
||||
image,
|
||||
title,
|
||||
imageHeight,
|
||||
titleSize = '12px',
|
||||
className,
|
||||
}: {
|
||||
slug: string
|
||||
image: string
|
||||
title: string
|
||||
imageHeight: string
|
||||
titleSize?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className={cn(
|
||||
'group/card flex flex-col overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]',
|
||||
className
|
||||
)}
|
||||
prefetch={false}
|
||||
>
|
||||
<div className='w-full overflow-hidden bg-[#141414]' style={{ height: imageHeight }}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
decoding='async'
|
||||
className='h-full w-full object-cover transition-transform duration-200 group-hover/card:scale-[1.02]'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-shrink-0 px-[10px] py-[6px]'>
|
||||
<span
|
||||
className='font-[430] font-season text-[#cdcdcd] leading-[140%]'
|
||||
style={{ fontSize: titleSize }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function BlogDropdown() {
|
||||
return (
|
||||
<div className='w-[560px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
|
||||
<div className='grid grid-cols-3 gap-[8px]'>
|
||||
<BlogCard
|
||||
slug={FEATURED_POST.slug}
|
||||
image={FEATURED_POST.image}
|
||||
title={FEATURED_POST.title}
|
||||
imageHeight='190px'
|
||||
titleSize='13px'
|
||||
className='col-span-2 row-span-2'
|
||||
/>
|
||||
|
||||
{POSTS.slice(0, 2).map((post) => (
|
||||
<BlogCard
|
||||
key={post.slug}
|
||||
slug={post.slug}
|
||||
image={post.image}
|
||||
title={post.title}
|
||||
imageHeight='72px'
|
||||
/>
|
||||
))}
|
||||
|
||||
{POSTS.slice(2).map((post) => (
|
||||
<BlogCard
|
||||
key={post.slug}
|
||||
slug={post.slug}
|
||||
image={post.image}
|
||||
title={post.title}
|
||||
imageHeight='72px'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { AgentIcon, GithubOutlineIcon, McpIcon } from '@/components/icons'
|
||||
|
||||
const PREVIEW_CARDS = [
|
||||
{
|
||||
title: 'Introduction',
|
||||
href: 'https://docs.sim.ai',
|
||||
image: '/landing/docs-getting-started.svg',
|
||||
},
|
||||
{
|
||||
title: 'Getting Started',
|
||||
href: 'https://docs.sim.ai/getting-started',
|
||||
image: '/landing/docs-intro.svg',
|
||||
},
|
||||
] as const
|
||||
|
||||
const RESOURCE_CARDS = [
|
||||
{
|
||||
title: 'Agent',
|
||||
description: 'Build AI agents',
|
||||
href: 'https://docs.sim.ai/blocks/agent',
|
||||
icon: AgentIcon,
|
||||
},
|
||||
{
|
||||
title: 'MCP',
|
||||
description: 'Connect tools',
|
||||
href: 'https://docs.sim.ai/mcp',
|
||||
icon: McpIcon,
|
||||
},
|
||||
{
|
||||
title: 'Self-hosting',
|
||||
description: 'Host on your infra',
|
||||
href: 'https://docs.sim.ai/self-hosting',
|
||||
icon: GithubOutlineIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
export function DocsDropdown() {
|
||||
return (
|
||||
<div className='w-[480px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
|
||||
<div className='grid grid-cols-2 gap-[10px]'>
|
||||
{PREVIEW_CARDS.map((card) => (
|
||||
<a
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group/card overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]'
|
||||
>
|
||||
<div className='h-[120px] w-full overflow-hidden bg-[#141414]'>
|
||||
<img
|
||||
src={card.image}
|
||||
alt={card.title}
|
||||
decoding='async'
|
||||
className='h-full w-full scale-[1.04] object-cover transition-transform duration-200 group-hover/card:scale-[1.06]'
|
||||
/>
|
||||
</div>
|
||||
<div className='px-[10px] py-[8px]'>
|
||||
<span className='font-[430] font-season text-[#cdcdcd] text-[13px]'>
|
||||
{card.title}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='mt-[8px] grid grid-cols-3 gap-[8px]'>
|
||||
{RESOURCE_CARDS.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<a
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex flex-col gap-[4px] rounded-[5px] border border-[#2A2A2A] px-[10px] py-[8px] transition-colors hover:border-[#3D3D3D] hover:bg-[#232323]'
|
||||
>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Icon className='h-[13px] w-[13px] flex-shrink-0 text-[#939393]' />
|
||||
<span className='font-[430] font-season text-[#cdcdcd] text-[12px]'>
|
||||
{card.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-season text-[#939393] text-[11px] leading-[130%]'>
|
||||
{card.description}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BlogDropdown } from '@/app/(home)/components/navbar/components/blog-dropdown'
|
||||
import { DocsDropdown } from '@/app/(home)/components/navbar/components/docs-dropdown'
|
||||
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
type DropdownId = 'docs' | 'blog' | null
|
||||
|
||||
interface NavLink {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
icon?: 'chevron'
|
||||
dropdown?: 'docs' | 'blog'
|
||||
}
|
||||
|
||||
const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true, icon: 'chevron', dropdown: 'docs' },
|
||||
{ label: 'Blog', href: '/blog', icon: 'chevron', dropdown: 'blog' },
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
]
|
||||
|
||||
/** 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 LOGO_CELL = 'flex items-center pl-[20px] lg:pl-[80px] pr-[20px]'
|
||||
const LINK_CELL = 'flex items-center px-[14px]'
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -30,15 +36,58 @@ interface NavbarProps {
|
||||
|
||||
export default function Navbar({ logoOnly = false }: NavbarProps) {
|
||||
const brand = getBrandConfig()
|
||||
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const openDropdown = useCallback((id: DropdownId) => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
setActiveDropdown(id)
|
||||
}, [])
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setActiveDropdown(null)
|
||||
closeTimerRef.current = null
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = mobileMenuOpen ? 'hidden' : ''
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [mobileMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1024px)')
|
||||
const handler = () => {
|
||||
if (mq.matches) setMobileMenuOpen(false)
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const anyHighlighted = activeDropdown !== null || hoveredLink !== null
|
||||
|
||||
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]'
|
||||
className='relative 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={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
{brand.name}
|
||||
@@ -67,37 +116,93 @@ export default function Navbar({ logoOnly = false }: NavbarProps) {
|
||||
|
||||
{!logoOnly && (
|
||||
<>
|
||||
{/* 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}
|
||||
<ul className='mt-[0.75px] hidden lg:flex'>
|
||||
{NAV_LINKS.map(({ label, href, external, icon, dropdown }) => {
|
||||
const hasDropdown = !!dropdown
|
||||
const isActive = hasDropdown && activeDropdown === dropdown
|
||||
const isThisHovered = hoveredLink === label
|
||||
const isHighlighted = isActive || isThisHovered
|
||||
const isDimmed = anyHighlighted && !isHighlighted
|
||||
const linkClass = cn(
|
||||
icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL,
|
||||
'transition-colors duration-200',
|
||||
isDimmed && 'text-[#F6F6F6]/60'
|
||||
)
|
||||
const chevron = icon === 'chevron' && <NavChevron open={isActive} />
|
||||
|
||||
if (hasDropdown) {
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
className='relative flex'
|
||||
onMouseEnter={() => openDropdown(dropdown)}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
{label}
|
||||
{icon === 'chevron' && (
|
||||
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li className='flex'>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(linkClass, 'h-full cursor-pointer')}
|
||||
aria-expanded={isActive}
|
||||
aria-haspopup='true'
|
||||
>
|
||||
{label}
|
||||
{chevron}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'-mt-[2px] absolute top-full left-0 z-50',
|
||||
isActive
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
)}
|
||||
style={{
|
||||
transform: isActive ? 'translateY(0)' : 'translateY(-6px)',
|
||||
transition: 'opacity 200ms ease, transform 200ms ease',
|
||||
}}
|
||||
>
|
||||
{dropdown === 'docs' && <DocsDropdown />}
|
||||
{dropdown === 'blog' && <BlogDropdown />}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
className='flex'
|
||||
onMouseEnter={() => setHoveredLink(label)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
>
|
||||
{external ? (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className={linkClass}>
|
||||
{label}
|
||||
{chevron}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href} className={linkClass} aria-label={label}>
|
||||
{label}
|
||||
{chevron}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li
|
||||
className={cn(
|
||||
'flex transition-opacity duration-200',
|
||||
anyHighlighted && hoveredLink !== 'github' && 'opacity-60'
|
||||
)}
|
||||
onMouseEnter={() => setHoveredLink('github')}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
>
|
||||
<GitHubStars />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className='flex-1' />
|
||||
<div className='hidden flex-1 lg:block' />
|
||||
|
||||
{/* CTAs */}
|
||||
<div className='flex items-center gap-[8px] px-[20px]'>
|
||||
<div className='hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex'>
|
||||
<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]'
|
||||
@@ -113,8 +218,168 @@ export default function Navbar({ logoOnly = false }: NavbarProps) {
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[32px] w-[32px] items-center justify-center rounded-[5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
<MobileMenuIcon open={mobileMenuOpen} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-x-0 top-[52px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[#1C1C1C] font-[430] font-season text-[14px] transition-all duration-200 lg:hidden',
|
||||
mobileMenuOpen ? 'visible opacity-100' : 'invisible opacity-0'
|
||||
)}
|
||||
>
|
||||
<ul className='flex flex-col'>
|
||||
{NAV_LINKS.map(({ label, href, external }) => (
|
||||
<li key={label} className='border-[#2A2A2A] border-b'>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
<ExternalArrowIcon />
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li className='border-[#2A2A2A] border-b'>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-[8px] px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className='mt-auto flex flex-col gap-[10px] p-[20px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavChevronProps {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated chevron matching the exact geometry of the emcn ChevronDown SVG.
|
||||
* Each arm rotates around its midpoint so the center vertex travels up/down
|
||||
* while the outer endpoints adjust — producing a Stripe-style morph.
|
||||
*/
|
||||
function NavChevron({ open }: NavChevronProps) {
|
||||
return (
|
||||
<svg width='9' height='6' viewBox='0 0 10 6' fill='none' className='mt-[1.5px] flex-shrink-0'>
|
||||
<line
|
||||
x1='1'
|
||||
y1='1'
|
||||
x2='5'
|
||||
y2='5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
style={{
|
||||
transformOrigin: '3px 3px',
|
||||
transform: open ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 250ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
/>
|
||||
<line
|
||||
x1='5'
|
||||
y1='5'
|
||||
x2='9'
|
||||
y2='1'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
style={{
|
||||
transformOrigin: '7px 3px',
|
||||
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 250ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenuIcon({ open }: { open: boolean }) {
|
||||
if (open) {
|
||||
return (
|
||||
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
|
||||
<path
|
||||
d='M1 1L13 13M13 1L1 13'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg width='16' height='12' viewBox='0 0 16 12' fill='none'>
|
||||
<path
|
||||
d='M0 1H16M0 6H16M0 11H16'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalArrowIcon() {
|
||||
return (
|
||||
<svg width='12' height='12' viewBox='0 0 12 12' fill='none' className='text-[#666]'>
|
||||
<path
|
||||
d='M3.5 2.5H9.5V8.5M9 3L3 9'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
features: [
|
||||
'1,000 credits (trial)',
|
||||
'5GB file storage',
|
||||
'3 tables · 1,000 rows each',
|
||||
'5 min execution limit',
|
||||
'Limited log retention',
|
||||
'CLI/SDK Access',
|
||||
'7-day log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
@@ -36,11 +37,12 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
billingPeriod: 'per month',
|
||||
color: '#00F701',
|
||||
features: [
|
||||
'6,000 credits/mo',
|
||||
'+50 daily refresh credits',
|
||||
'150 runs/min (sync)',
|
||||
'50 min sync execution limit',
|
||||
'6,000 credits/mo · +50/day',
|
||||
'50GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 150 runs/min',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
@@ -52,11 +54,12 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
billingPeriod: 'per month',
|
||||
color: '#FA4EDF',
|
||||
features: [
|
||||
'25,000 credits/mo',
|
||||
'+200 daily refresh credits',
|
||||
'300 runs/min (sync)',
|
||||
'50 min sync execution limit',
|
||||
'25,000 credits/mo · +200/day',
|
||||
'500GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 300 runs/min',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
@@ -66,7 +69,15 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
description: 'For organizations needing security and scale',
|
||||
price: 'Custom',
|
||||
color: '#FFCC02',
|
||||
features: ['Custom infra limits', 'SSO', 'SOC2', 'Self hosting', 'Dedicated support'],
|
||||
features: [
|
||||
'Custom credits & infra limits',
|
||||
'Custom file storage',
|
||||
'10,000 tables · 1M rows each',
|
||||
'Custom execution limits',
|
||||
'Unlimited log retention',
|
||||
'SSO & SCIM · SOC2 & HIPAA',
|
||||
'Self hosting · Dedicated support',
|
||||
],
|
||||
cta: { label: 'Book a demo', href: '/contact' },
|
||||
},
|
||||
]
|
||||
@@ -114,12 +125,12 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
{isEnterprise ? (
|
||||
<a
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</a>
|
||||
</Link>
|
||||
) : isPro ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
@@ -174,7 +185,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[100px] pb-[80px] sm:px-8 md:px-[80px]'>
|
||||
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
@@ -193,7 +204,7 @@ export default function Pricing() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
<div className='mt-8 grid grid-cols-1 gap-4 sm:mt-10 sm:grid-cols-2 md:mt-12 lg:grid-cols-4'>
|
||||
{PRICING_TIERS.map((tier) => (
|
||||
<PricingCard key={tier.id} tier={tier} />
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { AnimatePresence, type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
@@ -349,8 +349,17 @@ export default function Templates() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isPreparingTemplate, setIsPreparingTemplate] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1023px)')
|
||||
setIsMobile(mq.matches)
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
@@ -415,8 +424,8 @@ export default function Templates() {
|
||||
|
||||
<div className='bg-[#1C1C1C]'>
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={160}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
@@ -440,7 +449,7 @@ export default function Templates() {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='px-[80px] pt-[100px]'>
|
||||
<div className='px-[20px] pt-[60px] lg:px-[80px] lg:pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
@@ -457,84 +466,132 @@ export default function Templates() {
|
||||
|
||||
<h2
|
||||
id='templates-heading'
|
||||
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
className='font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
Pre-built templates for every use case—pick one, swap <br />
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
|
||||
Pre-built templates for every use case—pick one, swap{' '}
|
||||
<br className='hidden lg:inline' />
|
||||
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='mt-[40px] flex border-[#2A2A2A] border-y lg:mt-[73px]'>
|
||||
<div className='shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid
|
||||
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-r p-[4px]'
|
||||
cols={2}
|
||||
rows={55}
|
||||
gap={4}
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid
|
||||
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-r p-[6px]'
|
||||
cols={8}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-1'>
|
||||
<div className='flex min-w-0 flex-1 flex-col lg:flex-row'>
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Workflow templates'
|
||||
className='flex w-[300px] shrink-0 flex-col border-[#2A2A2A] border-r'
|
||||
className='flex w-full shrink-0 flex-col border-[#2A2A2A] lg:w-[300px] lg: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 key={workflow.id}>
|
||||
<button
|
||||
id={`template-tab-${index}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
aria-controls={TEMPLATES_PANEL_ID}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
'relative w-full text-left',
|
||||
isActive
|
||||
? 'z-10'
|
||||
: cn(
|
||||
'flex items-center px-[12px] py-[10px] hover:bg-[#232323]/50',
|
||||
index < TEMPLATE_WORKFLOWS.length - 1 &&
|
||||
'shadow-[inset_0_-1px_0_0_#2A2A2A]'
|
||||
)
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<AnimatePresence>
|
||||
{isActive && isMobile && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div className='aspect-[16/10] w-full border-[#2A2A2A] border-y bg-[#1b1b1b]'>
|
||||
<LandingPreviewWorkflow
|
||||
workflow={workflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<div className='p-[12px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='inline-flex h-[32px] w-full cursor-pointer items-center justify-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -582,12 +639,24 @@ export default function Templates() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
<div className='shrink-0'>
|
||||
<div className='h-full lg:hidden'>
|
||||
<DotGrid
|
||||
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-l p-[4px]'
|
||||
cols={2}
|
||||
rows={55}
|
||||
gap={4}
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden h-full lg:block'>
|
||||
<DotGrid
|
||||
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-l p-[6px]'
|
||||
cols={8}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,8 +28,8 @@ import {
|
||||
* 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).
|
||||
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration) ->
|
||||
* enterprise (Enterprise) -> pricing (Pricing) -> testimonials (Testimonials).
|
||||
*/
|
||||
export default async function Landing() {
|
||||
return (
|
||||
@@ -43,8 +43,8 @@ export default async function Landing() {
|
||||
<Templates />
|
||||
<Features />
|
||||
<Collaboration />
|
||||
<Pricing />
|
||||
<Enterprise />
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -9,7 +9,7 @@ export function BackLink() {
|
||||
|
||||
return (
|
||||
<Link
|
||||
href='/studio'
|
||||
href='/blog'
|
||||
className='group flex items-center gap-1 text-[#999] text-sm hover:text-[#ECECEC]'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -21,7 +21,7 @@ export function BackLink() {
|
||||
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
Back to Sim Studio
|
||||
Back to Blog
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { FAQ } from '@/lib/blog/faq'
|
||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
|
||||
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
|
||||
import { BackLink } from '@/app/(landing)/blog/[slug]/back-link'
|
||||
import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getAllPostMeta()
|
||||
@@ -95,7 +95,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
|
||||
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
<h2 className='mb-4 font-[500] text-[#ECECEC] text-[24px]'>Related posts</h2>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
|
||||
{related.map((p) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
@@ -31,7 +31,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
url: `https://sim.ai/studio/authors/${author.id}`,
|
||||
url: `https://sim.ai/blog/authors/${author.id}`,
|
||||
sameAs: author.url ? [author.url] : [],
|
||||
image: author.avatarUrl,
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
{posts.map((p) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { PostGrid } from '@/app/(landing)/studio/post-grid'
|
||||
import { PostGrid } from '@/app/(landing)/blog/post-grid'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Studio',
|
||||
title: 'Blog',
|
||||
description: 'Announcements, insights, and guides from the Sim team.',
|
||||
}
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export default async function StudioIndex({
|
||||
export default async function BlogIndex({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; tag?: string }>
|
||||
@@ -36,11 +36,11 @@ export default async function StudioIndex({
|
||||
const posts = sorted.slice(start, start + perPage)
|
||||
// Tag filter chips are intentionally disabled for now.
|
||||
// const tags = await getAllTags()
|
||||
const studioJsonLd = {
|
||||
const blogJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
name: 'Sim Studio',
|
||||
url: 'https://sim.ai/studio',
|
||||
name: 'Sim Blog',
|
||||
url: 'https://sim.ai/blog',
|
||||
description: 'Announcements, insights, and guides for building AI agent workflows.',
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ export default async function StudioIndex({
|
||||
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(studioJsonLd) }}
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
|
||||
/>
|
||||
<h1 className='mb-3 font-[500] text-[#ECECEC] text-[40px] leading-tight sm:text-[56px]'>
|
||||
Sim Studio
|
||||
Blog
|
||||
</h1>
|
||||
<p className='mb-10 text-[#999] text-[18px]'>
|
||||
Announcements, insights, and guides for building AI agent workflows.
|
||||
@@ -59,9 +59,9 @@ export default async function StudioIndex({
|
||||
|
||||
{/* Tag filter chips hidden until we have more posts */}
|
||||
{/* <div className='mb-10 flex flex-wrap gap-3'>
|
||||
<Link href='/studio' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
|
||||
<Link href='/blog' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
|
||||
{tags.map((t) => (
|
||||
<Link key={t.tag} href={`/studio?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
|
||||
<Link key={t.tag} href={`/blog?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
|
||||
{t.tag} ({t.count})
|
||||
</Link>
|
||||
))}
|
||||
@@ -74,7 +74,7 @@ export default async function StudioIndex({
|
||||
<div className='mt-10 flex items-center justify-center gap-3'>
|
||||
{pageNum > 1 && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
Previous
|
||||
@@ -85,7 +85,7 @@ export default async function StudioIndex({
|
||||
</span>
|
||||
{pageNum < totalPages && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
Next
|
||||
@@ -26,7 +26,7 @@ export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, index) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
|
||||
<Link key={p.slug} href={`/blog/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-[#2A2A2A] transition-colors duration-300 hover:border-[#3d3d3d]'>
|
||||
{/* Image container with fixed aspect ratio to prevent layout shift */}
|
||||
<div className='relative aspect-video w-full overflow-hidden'>
|
||||
@@ -11,7 +11,7 @@ export async function GET() {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Sim Studio</title>
|
||||
<title>Sim Blog</title>
|
||||
<link>${site}</link>
|
||||
<description>Announcements, insights, and guides for AI agent workflows.</description>
|
||||
${items
|
||||
@@ -13,7 +13,7 @@ export default async function TagsIndex() {
|
||||
<h1 className='mb-6 font-[500] text-[#ECECEC] text-[32px] leading-tight'>Browse by tag</h1>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<Link
|
||||
href='/studio'
|
||||
href='/blog'
|
||||
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
All
|
||||
@@ -21,7 +21,7 @@ export default async function TagsIndex() {
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t.tag}
|
||||
href={`/studio?tag=${encodeURIComponent(t.tag)}`}
|
||||
href={`/blog?tag=${encodeURIComponent(t.tag)}`}
|
||||
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
{t.tag} ({t.count})
|
||||
@@ -57,10 +57,10 @@ export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
Enterprise
|
||||
</Link>
|
||||
<Link
|
||||
href='/studio'
|
||||
href='/blog'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Sim Studio
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog'
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function LegalLayout({ title, children }: LegalLayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHosted && <Footer />}
|
||||
{isHosted && <Footer hideCTA />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type AppSession = {
|
||||
emailVerified?: boolean
|
||||
name?: string | null
|
||||
image?: string | null
|
||||
role?: string
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
} | null
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname.startsWith('/verify') ||
|
||||
pathname.startsWith('/changelog') ||
|
||||
pathname.startsWith('/chat') ||
|
||||
pathname.startsWith('/studio') ||
|
||||
pathname.startsWith('/blog') ||
|
||||
pathname.startsWith('/resume') ||
|
||||
pathname.startsWith('/form') ||
|
||||
pathname.startsWith('/oauth')
|
||||
|
||||
@@ -142,7 +142,7 @@ export async function POST(request: NextRequest) {
|
||||
quantity: currentQuantity,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
proration_behavior: 'always_invoice',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -302,6 +302,7 @@ export async function POST(req: NextRequest) {
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
promptForToolApproval: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -315,6 +316,7 @@ export async function POST(req: NextRequest) {
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
promptForToolApproval: true,
|
||||
})
|
||||
|
||||
const responseData = {
|
||||
|
||||
@@ -102,7 +102,7 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
|
||||
throw new FileNotFoundError(`File not found: ${filename}`)
|
||||
}
|
||||
|
||||
const filePath = findLocalFile(filename)
|
||||
const filePath = await findLocalFile(filename)
|
||||
|
||||
if (!filePath) {
|
||||
throw new FileNotFoundError(`File not found: ${filename}`)
|
||||
@@ -228,7 +228,7 @@ async function handleCloudProxyPublic(
|
||||
|
||||
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
|
||||
try {
|
||||
const filePath = findLocalFile(filename)
|
||||
const filePath = await findLocalFile(filename)
|
||||
|
||||
if (!filePath) {
|
||||
throw new FileNotFoundError(`File not found: ${filename}`)
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function POST(request: NextRequest) {
|
||||
const uploadResults = []
|
||||
|
||||
for (const file of files) {
|
||||
const originalName = file.name || 'untitled'
|
||||
const originalName = file.name || 'untitled.md'
|
||||
|
||||
if (!validateFileExtension(originalName)) {
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('extractFilename', () => {
|
||||
|
||||
describe('findLocalFile - Path Traversal Security Tests', () => {
|
||||
describe('path traversal attack prevention', () => {
|
||||
it.concurrent('should reject classic path traversal attacks', () => {
|
||||
it.concurrent('should reject classic path traversal attacks', async () => {
|
||||
const maliciousInputs = [
|
||||
'../../../etc/passwd',
|
||||
'..\\..\\..\\windows\\system32\\config\\sam',
|
||||
@@ -340,35 +340,35 @@ describe('findLocalFile - Path Traversal Security Tests', () => {
|
||||
'..\\config.ini',
|
||||
]
|
||||
|
||||
maliciousInputs.forEach((input) => {
|
||||
const result = findLocalFile(input)
|
||||
for (const input of maliciousInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject encoded path traversal attempts', () => {
|
||||
it.concurrent('should reject encoded path traversal attempts', async () => {
|
||||
const encodedInputs = [
|
||||
'%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd
|
||||
'..%2f..%2fetc%2fpasswd',
|
||||
'..%5c..%5cconfig.ini',
|
||||
]
|
||||
|
||||
encodedInputs.forEach((input) => {
|
||||
const result = findLocalFile(input)
|
||||
for (const input of encodedInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject mixed path separators', () => {
|
||||
it.concurrent('should reject mixed path separators', async () => {
|
||||
const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32']
|
||||
|
||||
mixedInputs.forEach((input) => {
|
||||
const result = findLocalFile(input)
|
||||
for (const input of mixedInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject filenames with dangerous characters', () => {
|
||||
it.concurrent('should reject filenames with dangerous characters', async () => {
|
||||
const dangerousInputs = [
|
||||
'file:with:colons.txt',
|
||||
'file|with|pipes.txt',
|
||||
@@ -376,43 +376,45 @@ describe('findLocalFile - Path Traversal Security Tests', () => {
|
||||
'file*with*asterisks.txt',
|
||||
]
|
||||
|
||||
dangerousInputs.forEach((input) => {
|
||||
const result = findLocalFile(input)
|
||||
for (const input of dangerousInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject null and empty inputs', () => {
|
||||
expect(findLocalFile('')).toBeNull()
|
||||
expect(findLocalFile(' ')).toBeNull()
|
||||
expect(findLocalFile('\t\n')).toBeNull()
|
||||
it.concurrent('should reject null and empty inputs', async () => {
|
||||
expect(await findLocalFile('')).toBeNull()
|
||||
expect(await findLocalFile(' ')).toBeNull()
|
||||
expect(await findLocalFile('\t\n')).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should reject filenames that become empty after sanitization', () => {
|
||||
it.concurrent('should reject filenames that become empty after sanitization', async () => {
|
||||
const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..']
|
||||
|
||||
emptyAfterSanitization.forEach((input) => {
|
||||
const result = findLocalFile(input)
|
||||
for (const input of emptyAfterSanitization) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('security validation passes for legitimate files', () => {
|
||||
it.concurrent('should accept properly formatted filenames without throwing errors', () => {
|
||||
const legitimateInputs = [
|
||||
'document.pdf',
|
||||
'image.png',
|
||||
'data.csv',
|
||||
'report-2024.doc',
|
||||
'file_with_underscores.txt',
|
||||
'file-with-dashes.json',
|
||||
]
|
||||
it.concurrent(
|
||||
'should accept properly formatted filenames without throwing errors',
|
||||
async () => {
|
||||
const legitimateInputs = [
|
||||
'document.pdf',
|
||||
'image.png',
|
||||
'data.csv',
|
||||
'report-2024.doc',
|
||||
'file_with_underscores.txt',
|
||||
'file-with-dashes.json',
|
||||
]
|
||||
|
||||
legitimateInputs.forEach((input) => {
|
||||
// Should not throw security errors for legitimate filenames
|
||||
expect(() => findLocalFile(input)).not.toThrow()
|
||||
})
|
||||
})
|
||||
for (const input of legitimateInputs) {
|
||||
await expect(findLocalFile(input)).resolves.toBeDefined()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { UPLOAD_DIR } from '@/lib/uploads/config'
|
||||
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
const logger = createLogger('FilesUtils')
|
||||
@@ -123,76 +120,29 @@ export function extractFilename(path: string): string {
|
||||
return filename
|
||||
}
|
||||
|
||||
function sanitizeFilename(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
throw new Error('Invalid filename provided')
|
||||
}
|
||||
|
||||
if (!filename.includes('/')) {
|
||||
throw new Error('File key must include a context prefix (e.g., kb/, workspace/, execution/)')
|
||||
}
|
||||
|
||||
const segments = filename.split('/')
|
||||
|
||||
const sanitizedSegments = segments.map((segment) => {
|
||||
if (segment === '..' || segment === '.') {
|
||||
throw new Error('Path traversal detected')
|
||||
}
|
||||
|
||||
const sanitized = segment.replace(/\.\./g, '').replace(/[\\]/g, '').replace(/^\./g, '').trim()
|
||||
|
||||
if (!sanitized) {
|
||||
throw new Error('Invalid or empty path segment after sanitization')
|
||||
}
|
||||
|
||||
if (
|
||||
sanitized.includes(':') ||
|
||||
sanitized.includes('|') ||
|
||||
sanitized.includes('?') ||
|
||||
sanitized.includes('*') ||
|
||||
sanitized.includes('\x00') ||
|
||||
/[\x00-\x1F\x7F]/.test(sanitized)
|
||||
) {
|
||||
throw new Error('Path segment contains invalid characters')
|
||||
}
|
||||
|
||||
return sanitized
|
||||
})
|
||||
|
||||
return sanitizedSegments.join(path.sep)
|
||||
}
|
||||
|
||||
export function findLocalFile(filename: string): string | null {
|
||||
export async function findLocalFile(filename: string): Promise<string | null> {
|
||||
try {
|
||||
const sanitizedFilename = sanitizeFileKey(filename)
|
||||
|
||||
// Reject if sanitized filename is empty or only contains path separators/dots
|
||||
if (!sanitizedFilename || !sanitizedFilename.trim() || /^[/\\.\s]+$/.test(sanitizedFilename)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const possiblePaths = [
|
||||
path.join(UPLOAD_DIR, sanitizedFilename),
|
||||
path.join(process.cwd(), 'uploads', sanitizedFilename),
|
||||
]
|
||||
const { existsSync } = await import('fs')
|
||||
const path = await import('path')
|
||||
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server')
|
||||
|
||||
for (const filePath of possiblePaths) {
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
const allowedDirs = [path.resolve(UPLOAD_DIR), path.resolve(process.cwd(), 'uploads')]
|
||||
const resolvedPath = path.join(UPLOAD_DIR_SERVER, sanitizedFilename)
|
||||
|
||||
// Must be within allowed directory but NOT the directory itself
|
||||
const isWithinAllowedDir = allowedDirs.some(
|
||||
(allowedDir) =>
|
||||
resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir
|
||||
)
|
||||
if (
|
||||
!resolvedPath.startsWith(UPLOAD_DIR_SERVER + path.sep) ||
|
||||
resolvedPath === UPLOAD_DIR_SERVER
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isWithinAllowedDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
return resolvedPath
|
||||
}
|
||||
if (existsSync(resolvedPath)) {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
248
apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts
Normal file
248
apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { document } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createDocumentRecords,
|
||||
deleteDocument,
|
||||
getProcessingConfig,
|
||||
processDocumentsWithQueue,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('DocumentUpsertAPI')
|
||||
|
||||
const UpsertDocumentSchema = z.object({
|
||||
documentId: z.string().optional(),
|
||||
filename: z.string().min(1, 'Filename is required'),
|
||||
fileUrl: z.string().min(1, 'File URL is required'),
|
||||
fileSize: z.number().min(1, 'File size must be greater than 0'),
|
||||
mimeType: z.string().min(1, 'MIME type is required'),
|
||||
documentTagsData: z.string().optional(),
|
||||
processingOptions: z.object({
|
||||
chunkSize: z.number().min(100).max(4000),
|
||||
minCharactersPerChunk: z.number().min(1).max(2000),
|
||||
recipe: z.string(),
|
||||
lang: z.string(),
|
||||
chunkOverlap: z.number().min(0).max(500),
|
||||
}),
|
||||
workflowId: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
logger.info(`[${requestId}] Knowledge base document upsert request`, {
|
||||
knowledgeBaseId,
|
||||
hasDocumentId: !!body.documentId,
|
||||
filename: body.filename,
|
||||
})
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const validatedData = UpsertDocumentSchema.parse(body)
|
||||
|
||||
if (validatedData.workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: validatedData.workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if ('notFound' in accessCheck && accessCheck.notFound) {
|
||||
logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`)
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let existingDocumentId: string | null = null
|
||||
let isUpdate = false
|
||||
|
||||
if (validatedData.documentId) {
|
||||
const existingDoc = await db
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.id, validatedData.documentId),
|
||||
eq(document.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingDoc.length > 0) {
|
||||
existingDocumentId = existingDoc[0].id
|
||||
}
|
||||
} else {
|
||||
const docsByFilename = await db
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.filename, validatedData.filename),
|
||||
eq(document.knowledgeBaseId, knowledgeBaseId),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (docsByFilename.length > 0) {
|
||||
existingDocumentId = docsByFilename[0].id
|
||||
}
|
||||
}
|
||||
|
||||
if (existingDocumentId) {
|
||||
isUpdate = true
|
||||
logger.info(
|
||||
`[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old`
|
||||
)
|
||||
}
|
||||
|
||||
const createdDocuments = await createDocumentRecords(
|
||||
[
|
||||
{
|
||||
filename: validatedData.filename,
|
||||
fileUrl: validatedData.fileUrl,
|
||||
fileSize: validatedData.fileSize,
|
||||
mimeType: validatedData.mimeType,
|
||||
...(validatedData.documentTagsData && {
|
||||
documentTagsData: validatedData.documentTagsData,
|
||||
}),
|
||||
},
|
||||
],
|
||||
knowledgeBaseId,
|
||||
requestId
|
||||
)
|
||||
|
||||
const firstDocument = createdDocuments[0]
|
||||
if (!firstDocument) {
|
||||
logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`)
|
||||
return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (existingDocumentId) {
|
||||
try {
|
||||
await deleteDocument(existingDocumentId, requestId)
|
||||
} catch (deleteError) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`,
|
||||
deleteError
|
||||
)
|
||||
await deleteDocument(firstDocument.documentId, requestId).catch(() => {})
|
||||
return NextResponse.json({ error: 'Failed to replace existing document' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
processDocumentsWithQueue(
|
||||
createdDocuments,
|
||||
knowledgeBaseId,
|
||||
validatedData.processingOptions,
|
||||
requestId
|
||||
).catch((error: unknown) => {
|
||||
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
|
||||
})
|
||||
|
||||
try {
|
||||
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
||||
PlatformEvents.knowledgeBaseDocumentsUploaded({
|
||||
knowledgeBaseId,
|
||||
documentsCount: 1,
|
||||
uploadType: 'single',
|
||||
chunkSize: validatedData.processingOptions.chunkSize,
|
||||
recipe: validatedData.processingOptions.recipe,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
||||
actorId: userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED,
|
||||
resourceType: AuditResourceType.DOCUMENT,
|
||||
resourceId: knowledgeBaseId,
|
||||
resourceName: validatedData.filename,
|
||||
description: isUpdate
|
||||
? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`
|
||||
: `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`,
|
||||
metadata: {
|
||||
fileName: validatedData.filename,
|
||||
previousDocumentId: existingDocumentId,
|
||||
isUpdate,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
documentsCreated: [
|
||||
{
|
||||
documentId: firstDocument.documentId,
|
||||
filename: firstDocument.filename,
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
isUpdate,
|
||||
previousDocumentId: existingDocumentId,
|
||||
processingMethod: 'background',
|
||||
processingConfig: {
|
||||
maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments,
|
||||
batchSize: getProcessingConfig().batchSize,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid upsert request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error upserting document`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to upsert document'
|
||||
const isStorageLimitError =
|
||||
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
|
||||
const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found'
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming'
|
||||
import {
|
||||
createSSEStream,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
waitForPendingChatStream,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
|
||||
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
|
||||
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
|
||||
@@ -244,6 +248,10 @@ export async function POST(req: NextRequest) {
|
||||
{ selectedModel: '' }
|
||||
)
|
||||
|
||||
if (actualChatId) {
|
||||
await waitForPendingChatStream(actualChatId)
|
||||
}
|
||||
|
||||
const stream = createSSEStream({
|
||||
requestPayload,
|
||||
userId: authenticatedUserId,
|
||||
@@ -261,7 +269,8 @@ export async function POST(req: NextRequest) {
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/mothership',
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
interactive: true,
|
||||
promptForToolApproval: false,
|
||||
onComplete: async (result: OrchestratorResult) => {
|
||||
if (!actualChatId) return
|
||||
|
||||
@@ -270,6 +279,7 @@ export async function POST(req: NextRequest) {
|
||||
role: 'assistant' as const,
|
||||
content: result.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(result.requestId ? { requestId: result.requestId } : {}),
|
||||
}
|
||||
if (result.toolCalls.length > 0) {
|
||||
assistantMessage.toolCalls = result.toolCalls
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
quantity: newSeatCount,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations', // Stripe's default - charge/credit immediately
|
||||
proration_behavior: 'always_invoice',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -213,7 +213,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
oldSeats: currentSeats,
|
||||
newSeats: newSeatCount,
|
||||
updatedBy: session.user.id,
|
||||
prorationBehavior: 'create_prorations',
|
||||
prorationBehavior: 'always_invoice',
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
140
apps/sim/app/api/tools/box/upload/route.ts
Normal file
140
apps/sim/app/api/tools/box/upload/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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'
|
||||
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('BoxUploadAPI')
|
||||
|
||||
const BoxUploadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
parentFolderId: z.string().min(1, 'Parent folder ID is required'),
|
||||
file: FileInputSchema.optional().nullable(),
|
||||
fileContent: z.string().optional().nullable(),
|
||||
fileName: z.string().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 Box upload attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated Box upload request via ${authResult.authType}`)
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = BoxUploadSchema.parse(body)
|
||||
|
||||
let fileBuffer: Buffer
|
||||
let fileName: string
|
||||
|
||||
if (validatedData.file) {
|
||||
const userFiles = processFilesToUserFiles(
|
||||
[validatedData.file as RawFileInput],
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
|
||||
if (userFiles.length === 0) {
|
||||
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userFile = userFiles[0]
|
||||
logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`)
|
||||
|
||||
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
|
||||
fileName = validatedData.fileName || userFile.name
|
||||
} else if (validatedData.fileContent) {
|
||||
logger.info(`[${requestId}] Using legacy base64 content input`)
|
||||
fileBuffer = Buffer.from(validatedData.fileContent, 'base64')
|
||||
fileName = validatedData.fileName || 'file'
|
||||
} else {
|
||||
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Uploading to Box folder ${validatedData.parentFolderId}: ${fileName} (${fileBuffer.length} bytes)`
|
||||
)
|
||||
|
||||
const attributes = JSON.stringify({
|
||||
name: fileName,
|
||||
parent: { id: validatedData.parentFolderId },
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('attributes', attributes)
|
||||
formData.append(
|
||||
'file',
|
||||
new Blob([new Uint8Array(fileBuffer)], { type: 'application/octet-stream' }),
|
||||
fileName
|
||||
)
|
||||
|
||||
const response = await fetch('https://upload.box.com/api/2.0/files/content', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.message || 'Failed to upload file'
|
||||
logger.error(`[${requestId}] Box API error:`, { status: response.status, data })
|
||||
return NextResponse.json({ success: false, error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const file = data.entries?.[0]
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No file returned in upload response' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] File uploaded successfully: ${file.name} (ID: ${file.id})`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
id: file.id ?? '',
|
||||
name: file.name ?? '',
|
||||
size: file.size ?? 0,
|
||||
sha1: file.sha1 ?? null,
|
||||
createdAt: file.created_at ?? null,
|
||||
modifiedAt: file.modified_at ?? null,
|
||||
parentId: file.parent?.id ?? null,
|
||||
parentName: file.parent?.name ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Validation error:`, error.errors)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.errors[0]?.message || 'Validation failed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Unexpected error:`, error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
466
apps/sim/app/api/tools/docusign/route.ts
Normal file
466
apps/sim/app/api/tools/docusign/route.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
const logger = createLogger('DocuSignAPI')
|
||||
|
||||
interface DocuSignAccountInfo {
|
||||
accountId: string
|
||||
baseUri: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the user's DocuSign account info from their access token
|
||||
* by calling the DocuSign userinfo endpoint.
|
||||
*/
|
||||
async function resolveAccount(accessToken: string): Promise<DocuSignAccountInfo> {
|
||||
const response = await fetch('https://account-d.docusign.com/oauth/userinfo', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to resolve DocuSign account', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
})
|
||||
throw new Error(`Failed to resolve DocuSign account: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const accounts = data.accounts ?? []
|
||||
|
||||
const defaultAccount = accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0]
|
||||
if (!defaultAccount) {
|
||||
throw new Error('No DocuSign accounts found for this user')
|
||||
}
|
||||
|
||||
const baseUri = defaultAccount.base_uri
|
||||
if (!baseUri) {
|
||||
throw new Error('DocuSign account is missing base_uri')
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: defaultAccount.account_id,
|
||||
baseUri,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { accessToken, operation, ...params } = body
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ success: false, error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!operation) {
|
||||
return NextResponse.json({ success: false, error: 'Operation is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await resolveAccount(accessToken)
|
||||
const apiBase = `${account.baseUri}/restapi/v2.1/accounts/${account.accountId}`
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'send_envelope':
|
||||
return await handleSendEnvelope(apiBase, headers, params)
|
||||
case 'create_from_template':
|
||||
return await handleCreateFromTemplate(apiBase, headers, params)
|
||||
case 'get_envelope':
|
||||
return await handleGetEnvelope(apiBase, headers, params)
|
||||
case 'list_envelopes':
|
||||
return await handleListEnvelopes(apiBase, headers, params)
|
||||
case 'void_envelope':
|
||||
return await handleVoidEnvelope(apiBase, headers, params)
|
||||
case 'download_document':
|
||||
return await handleDownloadDocument(apiBase, headers, params)
|
||||
case 'list_templates':
|
||||
return await handleListTemplates(apiBase, headers, params)
|
||||
case 'list_recipients':
|
||||
return await handleListRecipients(apiBase, headers, params)
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Unknown operation: ${operation}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('DocuSign API error', { operation, error })
|
||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendEnvelope(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params
|
||||
|
||||
if (!signerEmail || !signerName || !emailSubject) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'signerEmail, signerName, and emailSubject are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let documentBase64 = ''
|
||||
let documentName = 'document.pdf'
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
const parsed = FileInputSchema.parse(file)
|
||||
const userFiles = processFilesToUserFiles([parsed as RawFileInput], 'docusign-send', logger)
|
||||
if (userFiles.length > 0) {
|
||||
const userFile = userFiles[0]
|
||||
const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger)
|
||||
documentBase64 = buffer.toString('base64')
|
||||
documentName = userFile.name
|
||||
}
|
||||
} catch (fileError) {
|
||||
logger.error('Failed to process file for DocuSign envelope', { fileError })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to process uploaded file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const envelopeBody: Record<string, unknown> = {
|
||||
emailSubject,
|
||||
status: (status as string) || 'sent',
|
||||
recipients: {
|
||||
signers: [
|
||||
{
|
||||
email: signerEmail,
|
||||
name: signerName,
|
||||
recipientId: '1',
|
||||
routingOrder: '1',
|
||||
tabs: {
|
||||
signHereTabs: [
|
||||
{
|
||||
anchorString: '/sig1/',
|
||||
anchorUnits: 'pixels',
|
||||
anchorXOffset: '0',
|
||||
anchorYOffset: '0',
|
||||
},
|
||||
],
|
||||
dateSignedTabs: [
|
||||
{
|
||||
anchorString: '/date1/',
|
||||
anchorUnits: 'pixels',
|
||||
anchorXOffset: '0',
|
||||
anchorYOffset: '0',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
carbonCopies: ccEmail
|
||||
? [
|
||||
{
|
||||
email: ccEmail,
|
||||
name: ccName || (ccEmail as string),
|
||||
recipientId: '2',
|
||||
routingOrder: '2',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
}
|
||||
|
||||
if (emailBody) {
|
||||
envelopeBody.emailBlurb = emailBody
|
||||
}
|
||||
|
||||
if (documentBase64) {
|
||||
envelopeBody.documents = [
|
||||
{
|
||||
documentBase64,
|
||||
name: documentName,
|
||||
fileExtension: documentName.split('.').pop() || 'pdf',
|
||||
documentId: '1',
|
||||
},
|
||||
]
|
||||
} else if (((status as string) || 'sent') === 'sent') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'A document file is required to send an envelope' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/envelopes`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(envelopeBody),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
logger.error('DocuSign send envelope failed', { data, status: response.status })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.message || data.errorCode || 'Failed to send envelope' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
async function handleCreateFromTemplate(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const { templateId, emailSubject, emailBody, templateRoles, status } = params
|
||||
|
||||
if (!templateId) {
|
||||
return NextResponse.json({ success: false, error: 'templateId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
let parsedRoles: unknown[] = []
|
||||
if (templateRoles) {
|
||||
if (typeof templateRoles === 'string') {
|
||||
try {
|
||||
parsedRoles = JSON.parse(templateRoles)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid JSON for templateRoles' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} else if (Array.isArray(templateRoles)) {
|
||||
parsedRoles = templateRoles
|
||||
}
|
||||
}
|
||||
|
||||
const envelopeBody: Record<string, unknown> = {
|
||||
templateId,
|
||||
status: (status as string) || 'sent',
|
||||
templateRoles: parsedRoles,
|
||||
}
|
||||
|
||||
if (emailSubject) envelopeBody.emailSubject = emailSubject
|
||||
if (emailBody) envelopeBody.emailBlurb = emailBody
|
||||
|
||||
const response = await fetch(`${apiBase}/envelopes`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(envelopeBody),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
logger.error('DocuSign create from template failed', { data, status: response.status })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.message || data.errorCode || 'Failed to create envelope from template',
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
async function handleGetEnvelope(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const { envelopeId } = params
|
||||
if (!envelopeId) {
|
||||
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${apiBase}/envelopes/${(envelopeId as string).trim()}?include=recipients,documents`,
|
||||
{ headers }
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.message || data.errorCode || 'Failed to get envelope' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
async function handleListEnvelopes(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
const fromDate = params.fromDate as string | undefined
|
||||
if (fromDate) {
|
||||
queryParams.append('from_date', fromDate)
|
||||
} else {
|
||||
const thirtyDaysAgo = new Date()
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
||||
queryParams.append('from_date', thirtyDaysAgo.toISOString())
|
||||
}
|
||||
|
||||
if (params.toDate) queryParams.append('to_date', params.toDate as string)
|
||||
if (params.envelopeStatus) queryParams.append('status', params.envelopeStatus as string)
|
||||
if (params.searchText) queryParams.append('search_text', params.searchText as string)
|
||||
if (params.count) queryParams.append('count', params.count as string)
|
||||
|
||||
const response = await fetch(`${apiBase}/envelopes?${queryParams}`, { headers })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.message || data.errorCode || 'Failed to list envelopes' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
async function handleVoidEnvelope(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const { envelopeId, voidedReason } = params
|
||||
if (!envelopeId) {
|
||||
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
|
||||
}
|
||||
if (!voidedReason) {
|
||||
return NextResponse.json({ success: false, error: 'voidedReason is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ status: 'voided', voidedReason }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.message || data.errorCode || 'Failed to void envelope' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ envelopeId, status: 'voided' })
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const { envelopeId, documentId } = params
|
||||
if (!envelopeId) {
|
||||
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const docId = (documentId as string) || 'combined'
|
||||
|
||||
const response = await fetch(
|
||||
`${apiBase}/envelopes/${(envelopeId as string).trim()}/documents/${docId}`,
|
||||
{
|
||||
headers: { Authorization: headers.Authorization },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorText = ''
|
||||
try {
|
||||
errorText = await response.text()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Failed to download document: ${response.status} ${errorText}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/pdf'
|
||||
const contentDisposition = response.headers.get('content-disposition') || ''
|
||||
let fileName = `document-${docId}.pdf`
|
||||
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (filenameMatch) {
|
||||
fileName = filenameMatch[1].replace(/['"]/g, '')
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
const base64Content = buffer.toString('base64')
|
||||
|
||||
return NextResponse.json({ base64Content, mimeType: contentType, fileName })
|
||||
}
|
||||
|
||||
async function handleListTemplates(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.searchText) queryParams.append('search_text', params.searchText as string)
|
||||
if (params.count) queryParams.append('count', params.count as string)
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
const url = queryString ? `${apiBase}/templates?${queryString}` : `${apiBase}/templates`
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.message || data.errorCode || 'Failed to list templates' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
async function handleListRecipients(
|
||||
apiBase: string,
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
const { envelopeId } = params
|
||||
if (!envelopeId) {
|
||||
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}/recipients`, {
|
||||
headers,
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.message || data.errorCode || 'Failed to list recipients' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
67
apps/sim/app/api/tools/workday/assign-onboarding/route.ts
Normal file
67
apps/sim/app/api/tools/workday/assign-onboarding/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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'
|
||||
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayAssignOnboardingAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
workerId: z.string().min(1),
|
||||
onboardingPlanId: z.string().min(1),
|
||||
actionEventId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'humanResources',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const [result] = await client.Put_Onboarding_Plan_AssignmentAsync({
|
||||
Onboarding_Plan_Assignment_Data: {
|
||||
Onboarding_Plan_Reference: wdRef('Onboarding_Plan_ID', data.onboardingPlanId),
|
||||
Person_Reference: wdRef('WID', data.workerId),
|
||||
Action_Event_Reference: wdRef('Background_Check_ID', data.actionEventId),
|
||||
Assignment_Effective_Moment: new Date().toISOString(),
|
||||
Active: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
assignmentId: extractRefId(result?.Onboarding_Plan_Assignment_Reference),
|
||||
workerId: data.workerId,
|
||||
planId: data.onboardingPlanId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday assign onboarding failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
94
apps/sim/app/api/tools/workday/change-job/route.ts
Normal file
94
apps/sim/app/api/tools/workday/change-job/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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'
|
||||
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayChangeJobAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
workerId: z.string().min(1),
|
||||
effectiveDate: z.string().min(1),
|
||||
newPositionId: z.string().optional(),
|
||||
newJobProfileId: z.string().optional(),
|
||||
newLocationId: z.string().optional(),
|
||||
newSupervisoryOrgId: z.string().optional(),
|
||||
reason: z.string().min(1, 'Reason is required for job changes'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const changeJobDetailData: Record<string, unknown> = {
|
||||
Reason_Reference: wdRef('Change_Job_Subcategory_ID', data.reason),
|
||||
}
|
||||
if (data.newPositionId) {
|
||||
changeJobDetailData.Position_Reference = wdRef('Position_ID', data.newPositionId)
|
||||
}
|
||||
if (data.newJobProfileId) {
|
||||
changeJobDetailData.Job_Profile_Reference = wdRef('Job_Profile_ID', data.newJobProfileId)
|
||||
}
|
||||
if (data.newLocationId) {
|
||||
changeJobDetailData.Location_Reference = wdRef('Location_ID', data.newLocationId)
|
||||
}
|
||||
if (data.newSupervisoryOrgId) {
|
||||
changeJobDetailData.Supervisory_Organization_Reference = wdRef(
|
||||
'Supervisory_Organization_ID',
|
||||
data.newSupervisoryOrgId
|
||||
)
|
||||
}
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'staffing',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const [result] = await client.Change_JobAsync({
|
||||
Business_Process_Parameters: {
|
||||
Auto_Complete: true,
|
||||
Run_Now: true,
|
||||
},
|
||||
Change_Job_Data: {
|
||||
Worker_Reference: wdRef('Employee_ID', data.workerId),
|
||||
Effective_Date: data.effectiveDate,
|
||||
Change_Job_Detail_Data: changeJobDetailData,
|
||||
},
|
||||
})
|
||||
|
||||
const eventRef = result?.Event_Reference
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
eventId: extractRefId(eventRef),
|
||||
workerId: data.workerId,
|
||||
effectiveDate: data.effectiveDate,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday change job failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
134
apps/sim/app/api/tools/workday/create-prehire/route.ts
Normal file
134
apps/sim/app/api/tools/workday/create-prehire/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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'
|
||||
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayCreatePrehireAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
legalName: z.string().min(1),
|
||||
email: z.string().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
countryCode: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
if (!data.email && !data.phoneNumber && !data.address) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'At least one contact method (email, phone, or address) is required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const parts = data.legalName.trim().split(/\s+/)
|
||||
const firstName = parts[0] ?? ''
|
||||
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : ''
|
||||
|
||||
if (!lastName) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Legal name must include both a first name and last name' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'staffing',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const contactData: Record<string, unknown> = {}
|
||||
if (data.email) {
|
||||
contactData.Email_Address_Data = [
|
||||
{
|
||||
Email_Address: data.email,
|
||||
Usage_Data: {
|
||||
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
|
||||
Public: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
if (data.phoneNumber) {
|
||||
contactData.Phone_Data = [
|
||||
{
|
||||
Phone_Number: data.phoneNumber,
|
||||
Phone_Device_Type_Reference: wdRef('Phone_Device_Type_ID', 'Landline'),
|
||||
Usage_Data: {
|
||||
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
|
||||
Public: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
if (data.address) {
|
||||
contactData.Address_Data = [
|
||||
{
|
||||
Formatted_Address: data.address,
|
||||
Usage_Data: {
|
||||
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
|
||||
Public: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const [result] = await client.Put_ApplicantAsync({
|
||||
Applicant_Data: {
|
||||
Personal_Data: {
|
||||
Name_Data: {
|
||||
Legal_Name_Data: {
|
||||
Name_Detail_Data: {
|
||||
Country_Reference: wdRef('ISO_3166-1_Alpha-2_Code', data.countryCode ?? 'US'),
|
||||
First_Name: firstName,
|
||||
Last_Name: lastName,
|
||||
},
|
||||
},
|
||||
},
|
||||
Contact_Information_Data: contactData,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const applicantRef = result?.Applicant_Reference
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
preHireId: extractRefId(applicantRef),
|
||||
descriptor: applicantRef?.attributes?.Descriptor ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday create prehire failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
101
apps/sim/app/api/tools/workday/get-compensation/route.ts
Normal file
101
apps/sim/app/api/tools/workday/get-compensation/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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'
|
||||
import {
|
||||
createWorkdaySoapClient,
|
||||
extractRefId,
|
||||
normalizeSoapArray,
|
||||
type WorkdayCompensationDataSoap,
|
||||
type WorkdayCompensationPlanSoap,
|
||||
type WorkdayWorkerSoap,
|
||||
} from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayGetCompensationAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
workerId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'humanResources',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const [result] = await client.Get_WorkersAsync({
|
||||
Request_References: {
|
||||
Worker_Reference: {
|
||||
ID: { attributes: { 'wd:type': 'Employee_ID' }, $value: data.workerId },
|
||||
},
|
||||
},
|
||||
Response_Group: {
|
||||
Include_Reference: true,
|
||||
Include_Compensation: true,
|
||||
},
|
||||
})
|
||||
|
||||
const worker =
|
||||
normalizeSoapArray(
|
||||
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
|
||||
)[0] ?? null
|
||||
const compensationData = worker?.Worker_Data?.Compensation_Data
|
||||
|
||||
const mapPlan = (p: WorkdayCompensationPlanSoap) => ({
|
||||
id: extractRefId(p.Compensation_Plan_Reference) ?? null,
|
||||
planName: p.Compensation_Plan_Reference?.attributes?.Descriptor ?? null,
|
||||
amount: p.Amount ?? p.Per_Unit_Amount ?? p.Individual_Target_Amount ?? null,
|
||||
currency: extractRefId(p.Currency_Reference) ?? null,
|
||||
frequency: extractRefId(p.Frequency_Reference) ?? null,
|
||||
})
|
||||
|
||||
const planTypeKeys: (keyof WorkdayCompensationDataSoap)[] = [
|
||||
'Employee_Base_Pay_Plan_Assignment_Data',
|
||||
'Employee_Salary_Unit_Plan_Assignment_Data',
|
||||
'Employee_Bonus_Plan_Assignment_Data',
|
||||
'Employee_Allowance_Plan_Assignment_Data',
|
||||
'Employee_Commission_Plan_Assignment_Data',
|
||||
'Employee_Stock_Plan_Assignment_Data',
|
||||
'Employee_Period_Salary_Plan_Assignment_Data',
|
||||
]
|
||||
|
||||
const compensationPlans: ReturnType<typeof mapPlan>[] = []
|
||||
for (const key of planTypeKeys) {
|
||||
for (const plan of normalizeSoapArray(compensationData?.[key])) {
|
||||
compensationPlans.push(mapPlan(plan))
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { compensationPlans },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday get compensation failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
94
apps/sim/app/api/tools/workday/get-organizations/route.ts
Normal file
94
apps/sim/app/api/tools/workday/get-organizations/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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'
|
||||
import {
|
||||
createWorkdaySoapClient,
|
||||
extractRefId,
|
||||
normalizeSoapArray,
|
||||
type WorkdayOrganizationSoap,
|
||||
} from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayGetOrganizationsAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
type: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'humanResources',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const limit = data.limit ?? 20
|
||||
const offset = data.offset ?? 0
|
||||
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
|
||||
|
||||
const [result] = await client.Get_OrganizationsAsync({
|
||||
Response_Filter: { Page: page, Count: limit },
|
||||
Request_Criteria: data.type
|
||||
? {
|
||||
Organization_Type_Reference: {
|
||||
ID: {
|
||||
attributes: { 'wd:type': 'Organization_Type_ID' },
|
||||
$value: data.type,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
Response_Group: { Include_Hierarchy_Data: true },
|
||||
})
|
||||
|
||||
const orgsArray = normalizeSoapArray(
|
||||
result?.Response_Data?.Organization as
|
||||
| WorkdayOrganizationSoap
|
||||
| WorkdayOrganizationSoap[]
|
||||
| undefined
|
||||
)
|
||||
|
||||
const organizations = orgsArray.map((o) => ({
|
||||
id: extractRefId(o.Organization_Reference) ?? null,
|
||||
descriptor: o.Organization_Descriptor ?? null,
|
||||
type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null,
|
||||
subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null,
|
||||
isActive: o.Organization_Data?.Inactive != null ? !o.Organization_Data.Inactive : null,
|
||||
}))
|
||||
|
||||
const total = result?.Response_Results?.Total_Results ?? organizations.length
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { organizations, total },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday get organizations failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
87
apps/sim/app/api/tools/workday/get-worker/route.ts
Normal file
87
apps/sim/app/api/tools/workday/get-worker/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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'
|
||||
import {
|
||||
createWorkdaySoapClient,
|
||||
extractRefId,
|
||||
normalizeSoapArray,
|
||||
type WorkdayWorkerSoap,
|
||||
} from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayGetWorkerAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
workerId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'humanResources',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const [result] = await client.Get_WorkersAsync({
|
||||
Request_References: {
|
||||
Worker_Reference: {
|
||||
ID: { attributes: { 'wd:type': 'Employee_ID' }, $value: data.workerId },
|
||||
},
|
||||
},
|
||||
Response_Group: {
|
||||
Include_Reference: true,
|
||||
Include_Personal_Information: true,
|
||||
Include_Employment_Information: true,
|
||||
Include_Compensation: true,
|
||||
Include_Organizations: true,
|
||||
},
|
||||
})
|
||||
|
||||
const worker =
|
||||
normalizeSoapArray(
|
||||
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
|
||||
)[0] ?? null
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
worker: worker
|
||||
? {
|
||||
id: extractRefId(worker.Worker_Reference) ?? null,
|
||||
descriptor: worker.Worker_Descriptor ?? null,
|
||||
personalData: worker.Worker_Data?.Personal_Data ?? null,
|
||||
employmentData: worker.Worker_Data?.Employment_Data ?? null,
|
||||
compensationData: worker.Worker_Data?.Compensation_Data ?? null,
|
||||
organizationData: worker.Worker_Data?.Organization_Data ?? null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday get worker failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
78
apps/sim/app/api/tools/workday/hire/route.ts
Normal file
78
apps/sim/app/api/tools/workday/hire/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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'
|
||||
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayHireAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
preHireId: z.string().min(1),
|
||||
positionId: z.string().min(1),
|
||||
hireDate: z.string().min(1),
|
||||
employeeType: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'staffing',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const [result] = await client.Hire_EmployeeAsync({
|
||||
Business_Process_Parameters: {
|
||||
Auto_Complete: true,
|
||||
Run_Now: true,
|
||||
},
|
||||
Hire_Employee_Data: {
|
||||
Applicant_Reference: wdRef('Applicant_ID', data.preHireId),
|
||||
Position_Reference: wdRef('Position_ID', data.positionId),
|
||||
Hire_Date: data.hireDate,
|
||||
Hire_Employee_Event_Data: {
|
||||
Employee_Type_Reference: wdRef('Employee_Type_ID', data.employeeType ?? 'Regular'),
|
||||
First_Day_of_Work: data.hireDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const employeeRef = result?.Employee_Reference
|
||||
const eventRef = result?.Event_Reference
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
workerId: extractRefId(employeeRef),
|
||||
employeeId: extractRefId(employeeRef),
|
||||
eventId: extractRefId(eventRef),
|
||||
hireDate: data.hireDate,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday hire employee failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
83
apps/sim/app/api/tools/workday/list-workers/route.ts
Normal file
83
apps/sim/app/api/tools/workday/list-workers/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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'
|
||||
import {
|
||||
createWorkdaySoapClient,
|
||||
extractRefId,
|
||||
normalizeSoapArray,
|
||||
type WorkdayWorkerSoap,
|
||||
} from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayListWorkersAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'humanResources',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const limit = data.limit ?? 20
|
||||
const offset = data.offset ?? 0
|
||||
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
|
||||
|
||||
const [result] = await client.Get_WorkersAsync({
|
||||
Response_Filter: { Page: page, Count: limit },
|
||||
Response_Group: {
|
||||
Include_Reference: true,
|
||||
Include_Personal_Information: true,
|
||||
Include_Employment_Information: true,
|
||||
},
|
||||
})
|
||||
|
||||
const workersArray = normalizeSoapArray(
|
||||
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
|
||||
)
|
||||
|
||||
const workers = workersArray.map((w) => ({
|
||||
id: extractRefId(w.Worker_Reference) ?? null,
|
||||
descriptor: w.Worker_Descriptor ?? null,
|
||||
personalData: w.Worker_Data?.Personal_Data ?? null,
|
||||
employmentData: w.Worker_Data?.Employment_Data ?? null,
|
||||
}))
|
||||
|
||||
const total = result?.Response_Results?.Total_Results ?? workers.length
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { workers, total },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday list workers failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
77
apps/sim/app/api/tools/workday/terminate/route.ts
Normal file
77
apps/sim/app/api/tools/workday/terminate/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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'
|
||||
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayTerminateAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
workerId: z.string().min(1),
|
||||
terminationDate: z.string().min(1),
|
||||
reason: z.string().min(1),
|
||||
notificationDate: z.string().optional(),
|
||||
lastDayOfWork: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'staffing',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const [result] = await client.Terminate_EmployeeAsync({
|
||||
Business_Process_Parameters: {
|
||||
Auto_Complete: true,
|
||||
Run_Now: true,
|
||||
},
|
||||
Terminate_Employee_Data: {
|
||||
Employee_Reference: wdRef('Employee_ID', data.workerId),
|
||||
Termination_Date: data.terminationDate,
|
||||
Terminate_Event_Data: {
|
||||
Primary_Reason_Reference: wdRef('Termination_Subcategory_ID', data.reason),
|
||||
Last_Day_of_Work: data.lastDayOfWork ?? data.terminationDate,
|
||||
Notification_Date: data.notificationDate ?? data.terminationDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const eventRef = result?.Event_Reference
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
eventId: extractRefId(eventRef),
|
||||
workerId: data.workerId,
|
||||
terminationDate: data.terminationDate,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday terminate employee failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
66
apps/sim/app/api/tools/workday/update-worker/route.ts
Normal file
66
apps/sim/app/api/tools/workday/update-worker/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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'
|
||||
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkdayUpdateWorkerAPI')
|
||||
|
||||
const RequestSchema = z.object({
|
||||
tenantUrl: z.string().min(1),
|
||||
tenant: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
workerId: z.string().min(1),
|
||||
fields: z.record(z.unknown()),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = RequestSchema.parse(body)
|
||||
|
||||
const client = await createWorkdaySoapClient(
|
||||
data.tenantUrl,
|
||||
data.tenant,
|
||||
'humanResources',
|
||||
data.username,
|
||||
data.password
|
||||
)
|
||||
|
||||
const [result] = await client.Change_Personal_InformationAsync({
|
||||
Business_Process_Parameters: {
|
||||
Auto_Complete: true,
|
||||
Run_Now: true,
|
||||
},
|
||||
Change_Personal_Information_Data: {
|
||||
Person_Reference: wdRef('Employee_ID', data.workerId),
|
||||
Personal_Information_Data: data.fields,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
eventId: extractRefId(result?.Personal_Information_Change_Event_Reference),
|
||||
workerId: data.workerId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workday update worker failed`, { error })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('SuperUserAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// GET /api/user/super-user - Check if current user is a super user (database status)
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized super user status check attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (currentUser.length === 0) {
|
||||
logger.warn(`[${requestId}] User not found: ${session.user.id}`)
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isSuperUser: currentUser[0].isSuperUser,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error checking super user status`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export async function GET() {
|
||||
emailPreferences: userSettings.emailPreferences ?? {},
|
||||
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
|
||||
showTrainingControls: userSettings.showTrainingControls ?? false,
|
||||
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
|
||||
superUserModeEnabled: userSettings.superUserModeEnabled ?? false,
|
||||
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
|
||||
snapToGridSize: userSettings.snapToGridSize ?? 0,
|
||||
showActionBar: userSettings.showActionBar ?? true,
|
||||
|
||||
@@ -267,11 +267,24 @@ async function createRejectedTask(
|
||||
* Format: "username@domain.com" or "Display Name <username@domain.com>"
|
||||
*/
|
||||
function extractSenderEmail(from: string): string {
|
||||
const match = from.match(/<([^>]+)>/)
|
||||
return (match?.[1] || from).toLowerCase().trim()
|
||||
const openBracket = from.indexOf('<')
|
||||
const closeBracket = from.indexOf('>', openBracket + 1)
|
||||
if (openBracket !== -1 && closeBracket !== -1) {
|
||||
return from
|
||||
.substring(openBracket + 1, closeBracket)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
}
|
||||
return from.toLowerCase().trim()
|
||||
}
|
||||
|
||||
function extractDisplayName(from: string): string | null {
|
||||
const match = from.match(/^(.+?)\s*</)
|
||||
return match?.[1]?.trim().replace(/^"|"$/g, '') || null
|
||||
const openBracket = from.indexOf('<')
|
||||
if (openBracket <= 0) return null
|
||||
const name = from.substring(0, openBracket).trim()
|
||||
if (!name) return null
|
||||
if (name.startsWith('"') && name.endsWith('"')) {
|
||||
return name.slice(1, -1) || null
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fileName = rawFile.name || 'untitled'
|
||||
const fileName = rawFile.name || 'untitled.md'
|
||||
|
||||
const maxSize = 100 * 1024 * 1024
|
||||
if (rawFile.size > maxSize) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ChangelogLayout({ children }: { children: React.ReactNod
|
||||
<Navbar />
|
||||
</header>
|
||||
{children}
|
||||
<Footer />
|
||||
<Footer hideCTA />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
|
||||
- [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
|
||||
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter()
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<Navbar />
|
||||
</header>
|
||||
<div className='relative z-30 flex flex-1 flex-col items-center justify-center px-4 pb-24'>
|
||||
<h1 className='font-[500] text-[48px] tracking-tight'>Page Not Found</h1>
|
||||
<p className='mt-2 text-[#999] text-[16px]'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-8 w-full max-w-[200px]'>
|
||||
<BrandedButton onClick={() => router.push('/')} showArrow={false}>
|
||||
Return to Home
|
||||
</BrandedButton>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='flex flex-col items-center gap-[12px]'>
|
||||
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||
<Link
|
||||
href='/'
|
||||
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
>
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -13,11 +13,11 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/studio`,
|
||||
url: `${baseUrl}/blog`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/studio/tags`,
|
||||
url: `${baseUrl}/blog/tags`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
||||
Array<{ organizationId: string; role: string }>
|
||||
>([])
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const isSuperUser = session?.user?.role === 'admin'
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isApproving, setIsApproving] = useState(false)
|
||||
@@ -186,21 +186,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSuperUserStatus = async () => {
|
||||
if (!currentUserId) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/super-user')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setIsSuperUser(data.isSuperUser || false)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching super user status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSuperUserStatus()
|
||||
fetchUserOrganizations()
|
||||
}, [currentUserId])
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { ErrorState, type ErrorStateProps } from './error'
|
||||
export { InlineRenameInput } from './inline-rename-input'
|
||||
export { MessageActions } from './message-actions'
|
||||
export { ownerCell } from './resource/components/owner-cell/owner-cell'
|
||||
export type {
|
||||
BreadcrumbEditing,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MessageActions } from './message-actions'
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export function MessageActions({ content, requestId }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
|
||||
const resetTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(type)
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!content && !requestId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='More options'
|
||||
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Ellipsis className='h-3 w-3' strokeWidth={2} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
disabled={!content}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
void copyToClipboard(content, 'message')
|
||||
}}
|
||||
>
|
||||
{copied === 'message' ? <Check /> : <Copy />}
|
||||
<span>Copy Message</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!requestId}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
if (requestId) {
|
||||
void copyToClipboard(requestId, 'request')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied === 'request' ? <Check /> : <Hash />}
|
||||
<span>Copy Request ID</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -215,16 +215,13 @@ function TextEditor({
|
||||
onSaveStatusChange?.(saveStatus)
|
||||
}, [saveStatus, onSaveStatusChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveRef) {
|
||||
saveRef.current = saveImmediately
|
||||
}
|
||||
return () => {
|
||||
if (saveRef) {
|
||||
saveRef.current = null
|
||||
}
|
||||
}
|
||||
}, [saveRef, saveImmediately])
|
||||
if (saveRef) saveRef.current = saveImmediately
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveRef) saveRef.current = null
|
||||
},
|
||||
[saveRef]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Columns2,
|
||||
Download,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
ResourceHeader,
|
||||
timeCell,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import {
|
||||
FileViewer,
|
||||
isPreviewable,
|
||||
@@ -149,6 +151,8 @@ export function Files() {
|
||||
}
|
||||
|
||||
const justCreatedFileIdRef = useRef<string | null>(null)
|
||||
const filesRef = useRef(files)
|
||||
filesRef.current = files
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
@@ -157,7 +161,7 @@ export function Files() {
|
||||
const [creatingFile, setCreatingFile] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
|
||||
@@ -312,7 +316,7 @@ export function Files() {
|
||||
if (isDirty) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
setShowPreview(false)
|
||||
setPreviewMode('editor')
|
||||
setSelectedFileId(null)
|
||||
}
|
||||
}, [isDirty])
|
||||
@@ -382,13 +386,11 @@ export function Files() {
|
||||
]
|
||||
)
|
||||
|
||||
const handleTogglePreview = useCallback(() => setShowPreview((prev) => !prev), [])
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
setShowPreview(false)
|
||||
setPreviewMode('editor')
|
||||
setSelectedFileId(null)
|
||||
}, [])
|
||||
|
||||
@@ -480,7 +482,13 @@ export function Files() {
|
||||
if (justCreatedFileIdRef.current && !isJustCreated) {
|
||||
justCreatedFileIdRef.current = null
|
||||
}
|
||||
setShowPreview(!isJustCreated)
|
||||
if (isJustCreated) {
|
||||
setPreviewMode('editor')
|
||||
} else {
|
||||
const file = selectedFileId ? filesRef.current.find((f) => f.id === selectedFileId) : null
|
||||
const canPreview = file ? isPreviewable(file) : false
|
||||
setPreviewMode(canPreview ? 'preview' : 'editor')
|
||||
}
|
||||
}, [selectedFileId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -504,10 +512,23 @@ export function Files() {
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [isDirty])
|
||||
|
||||
const handleCyclePreviewMode = useCallback(() => {
|
||||
setPreviewMode((prev) => {
|
||||
if (prev === 'editor') return 'split'
|
||||
if (prev === 'split') return 'preview'
|
||||
return 'editor'
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleTogglePreview = useCallback(() => {
|
||||
setPreviewMode((prev) => (prev === 'preview' ? 'editor' : 'preview'))
|
||||
}, [])
|
||||
|
||||
const fileActions = useMemo<HeaderAction[]>(() => {
|
||||
if (!selectedFile) return []
|
||||
const canEditText = isTextEditable(selectedFile)
|
||||
const canPreview = isPreviewable(selectedFile)
|
||||
const hasSplitView = canEditText && canPreview
|
||||
|
||||
const saveLabel =
|
||||
saveStatus === 'saving'
|
||||
@@ -518,16 +539,12 @@ export function Files() {
|
||||
? 'Save failed'
|
||||
: 'Save'
|
||||
|
||||
const nextModeLabel =
|
||||
previewMode === 'editor' ? 'Split' : previewMode === 'split' ? 'Preview' : 'Edit'
|
||||
const nextModeIcon =
|
||||
previewMode === 'editor' ? Columns2 : previewMode === 'split' ? Eye : Pencil
|
||||
|
||||
return [
|
||||
...(canPreview
|
||||
? [
|
||||
{
|
||||
label: showPreview ? 'Edit' : 'Preview',
|
||||
icon: showPreview ? Pencil : Eye,
|
||||
onClick: handleTogglePreview,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canEditText
|
||||
? [
|
||||
{
|
||||
@@ -540,6 +557,23 @@ export function Files() {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasSplitView
|
||||
? [
|
||||
{
|
||||
label: nextModeLabel,
|
||||
icon: nextModeIcon,
|
||||
onClick: handleCyclePreviewMode,
|
||||
},
|
||||
]
|
||||
: canPreview
|
||||
? [
|
||||
{
|
||||
label: previewMode === 'preview' ? 'Edit' : 'Preview',
|
||||
icon: previewMode === 'preview' ? Pencil : Eye,
|
||||
onClick: handleTogglePreview,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Download',
|
||||
icon: Download,
|
||||
@@ -554,7 +588,8 @@ export function Files() {
|
||||
}, [
|
||||
selectedFile,
|
||||
saveStatus,
|
||||
showPreview,
|
||||
previewMode,
|
||||
handleCyclePreviewMode,
|
||||
handleTogglePreview,
|
||||
handleSave,
|
||||
isDirty,
|
||||
@@ -580,8 +615,6 @@ export function Files() {
|
||||
}
|
||||
|
||||
if (selectedFile) {
|
||||
const canPreview = isPreviewable(selectedFile)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
@@ -595,7 +628,7 @@ export function Files() {
|
||||
file={selectedFile}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={userPermissions.canEdit === true}
|
||||
showPreview={showPreview && canPreview}
|
||||
previewMode={previewMode}
|
||||
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
|
||||
onDirtyChange={setIsDirty}
|
||||
onSaveStatusChange={setSaveStatus}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function AgentGroup({
|
||||
}, [expanded])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
{hasItems ? (
|
||||
<button
|
||||
type='button'
|
||||
@@ -87,7 +87,7 @@ export function AgentGroup({
|
||||
{hasItems && mounted && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3 transition-opacity duration-300 ease-out',
|
||||
'flex flex-col gap-[6px] transition-opacity duration-300 ease-out',
|
||||
expanded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
@@ -98,14 +98,15 @@ export function AgentGroup({
|
||||
toolName={item.data.toolName}
|
||||
displayTitle={item.data.displayTitle}
|
||||
status={item.data.status}
|
||||
result={item.data.result}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
<span
|
||||
key={`text-${idx}`}
|
||||
className='whitespace-pre-wrap pl-[24px] font-base text-[13px] text-[var(--text-secondary)]'
|
||||
className='pl-[24px] font-base text-[13px] text-[var(--text-secondary)]'
|
||||
>
|
||||
{item.content.trim()}
|
||||
</p>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { Loader } from '@/components/emcn'
|
||||
import type { ToolCallStatus } from '../../../../types'
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { PillsRing } from '@/components/emcn'
|
||||
import type { ToolCallResult, ToolCallStatus } from '../../../../types'
|
||||
import { getToolIcon } from '../../utils'
|
||||
|
||||
/** Tools that render as cards with result data on success. */
|
||||
const CARD_TOOLS = new Set<string>([
|
||||
'function_execute',
|
||||
'search_online',
|
||||
'scrape_page',
|
||||
'get_page_contents',
|
||||
'search_library_docs',
|
||||
'superagent',
|
||||
'run',
|
||||
'plan',
|
||||
'debug',
|
||||
'edit',
|
||||
'fast_edit',
|
||||
'custom_tool',
|
||||
'research',
|
||||
'agent',
|
||||
'job',
|
||||
])
|
||||
|
||||
function CircleCheck({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -40,29 +62,100 @@ export function CircleStop({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolCallItemProps {
|
||||
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
|
||||
if (status === 'executing') {
|
||||
return <PillsRing className='h-[15px] w-[15px] text-[var(--text-tertiary)]' animate />
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return <CircleStop className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
|
||||
}
|
||||
const Icon = getToolIcon(toolName)
|
||||
if (Icon) {
|
||||
return <Icon className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
|
||||
}
|
||||
return <CircleCheck className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
|
||||
}
|
||||
|
||||
function FlatToolLine({
|
||||
toolName,
|
||||
displayTitle,
|
||||
status,
|
||||
}: {
|
||||
toolName: string
|
||||
displayTitle: string
|
||||
status: ToolCallStatus
|
||||
}
|
||||
|
||||
export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemProps) {
|
||||
const Icon = getToolIcon(toolName)
|
||||
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center gap-[8px] pl-[24px]'>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
{status === 'executing' ? (
|
||||
<Loader className='h-[15px] w-[15px] text-[var(--text-tertiary)]' animate />
|
||||
) : status === 'cancelled' ? (
|
||||
<CircleStop className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
|
||||
) : Icon ? (
|
||||
<Icon className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<CircleCheck className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<StatusIcon status={status} toolName={toolName} />
|
||||
</div>
|
||||
<span className='font-base text-[13px] text-[var(--text-secondary)]'>{displayTitle}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatToolOutput(output: unknown): string {
|
||||
if (output === null || output === undefined) return ''
|
||||
if (typeof output === 'string') return output
|
||||
try {
|
||||
return JSON.stringify(output, null, 2)
|
||||
} catch {
|
||||
return String(output)
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolCallItemProps {
|
||||
toolName: string
|
||||
displayTitle: string
|
||||
status: ToolCallStatus
|
||||
result?: ToolCallResult
|
||||
}
|
||||
|
||||
export function ToolCallItem({ toolName, displayTitle, status, result }: ToolCallItemProps) {
|
||||
const showCard =
|
||||
CARD_TOOLS.has(toolName) &&
|
||||
status === 'success' &&
|
||||
result?.output !== undefined &&
|
||||
result?.output !== null
|
||||
|
||||
if (showCard) {
|
||||
return <ToolCallCard toolName={toolName} displayTitle={displayTitle} result={result!} />
|
||||
}
|
||||
|
||||
return <FlatToolLine toolName={toolName} displayTitle={displayTitle} status={status} />
|
||||
}
|
||||
|
||||
function ToolCallCard({
|
||||
toolName,
|
||||
displayTitle,
|
||||
result,
|
||||
}: {
|
||||
toolName: string
|
||||
displayTitle: string
|
||||
result: ToolCallResult
|
||||
}) {
|
||||
const body = useMemo(() => formatToolOutput(result.output), [result.output])
|
||||
const Icon = getToolIcon(toolName)
|
||||
const ResolvedIcon = Icon ?? CircleCheck
|
||||
|
||||
return (
|
||||
<div className='animate-stream-fade-in pl-[24px]'>
|
||||
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)]'>
|
||||
<div className='flex items-center gap-[8px] px-[10px] py-[6px]'>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<ResolvedIcon className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
<span className='font-base text-[13px] text-[var(--text-secondary)]'>{displayTitle}</span>
|
||||
</div>
|
||||
{body && (
|
||||
<div className='border-[var(--border)] border-t px-[10px] py-[6px]'>
|
||||
<pre className='max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all font-mono text-[12px] text-[var(--text-body)] leading-[1.5]'>
|
||||
{body}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,9 +216,9 @@ export function ChatContent({ content, isStreaming = false, onOptionSelect }: Ch
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{parsed.segments.map((segment, i) => {
|
||||
if (segment.type === 'text') {
|
||||
if (segment.type === 'text' || segment.type === 'thinking') {
|
||||
return (
|
||||
<div key={`text-${i}`} className={PROSE_CLASSES}>
|
||||
<div key={`${segment.type}-${i}`} className={PROSE_CLASSES}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{segment.content}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface CredentialTagData {
|
||||
|
||||
export type ContentSegment =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'thinking'; content: string }
|
||||
| { type: 'options'; data: OptionsTagData }
|
||||
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
|
||||
| { type: 'credential'; data: CredentialTagData }
|
||||
@@ -36,7 +37,7 @@ export interface ParsedSpecialContent {
|
||||
hasPendingTag: boolean
|
||||
}
|
||||
|
||||
const SPECIAL_TAG_NAMES = ['options', 'usage_upgrade', 'credential'] as const
|
||||
const SPECIAL_TAG_NAMES = ['thinking', 'options', 'usage_upgrade', 'credential'] as const
|
||||
|
||||
/**
|
||||
* Parses inline special tags (`<options>`, `<usage_upgrade>`) from streamed
|
||||
@@ -103,11 +104,17 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
|
||||
}
|
||||
|
||||
const body = content.slice(bodyStart, closeIdx)
|
||||
try {
|
||||
const data = JSON.parse(body)
|
||||
segments.push({ type: nearestTagName as 'options' | 'usage_upgrade' | 'credential', data })
|
||||
} catch {
|
||||
/* malformed JSON — drop the tag silently */
|
||||
if (nearestTagName === 'thinking') {
|
||||
if (body.trim()) {
|
||||
segments.push({ type: 'thinking', content: body })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const data = JSON.parse(body)
|
||||
segments.push({ type: nearestTagName as 'options' | 'usage_upgrade' | 'credential', data })
|
||||
} catch {
|
||||
/* malformed JSON — drop the tag silently */
|
||||
}
|
||||
}
|
||||
|
||||
cursor = closeIdx + closeTag.length
|
||||
@@ -137,6 +144,8 @@ interface SpecialTagsProps {
|
||||
*/
|
||||
export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) {
|
||||
switch (segment.type) {
|
||||
case 'thinking':
|
||||
return null
|
||||
case 'options':
|
||||
return <OptionsDisplay data={segment.data} onSelect={onOptionSelect} />
|
||||
case 'usage_upgrade':
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types'
|
||||
import { SUBAGENT_LABELS } from '../../types'
|
||||
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
|
||||
import type { AgentGroupItem } from './components'
|
||||
import { AgentGroup, ChatContent, CircleStop, Options } from './components'
|
||||
import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components'
|
||||
|
||||
interface TextSegment {
|
||||
type: 'text'
|
||||
@@ -47,8 +47,12 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
|
||||
return {
|
||||
id: tc.id,
|
||||
toolName: tc.name,
|
||||
displayTitle: tc.displayTitle || formatToolName(tc.name),
|
||||
displayTitle:
|
||||
tc.displayTitle ||
|
||||
TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title ||
|
||||
formatToolName(tc.name),
|
||||
status: tc.status,
|
||||
result: tc.result,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +82,15 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
|
||||
|
||||
if (block.type === 'text') {
|
||||
if (!block.content?.trim()) continue
|
||||
if (block.subagent && group && group.agentName === block.subagent) {
|
||||
const lastItem = group.items[group.items.length - 1]
|
||||
if (lastItem?.type === 'text') {
|
||||
lastItem.content += block.content
|
||||
} else {
|
||||
group.items.push({ type: 'text', content: block.content })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (group) {
|
||||
segments.push(group)
|
||||
group = null
|
||||
@@ -177,6 +190,14 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'subagent_end') {
|
||||
if (group) {
|
||||
segments.push(group)
|
||||
group = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'stopped') {
|
||||
if (group) {
|
||||
segments.push(group)
|
||||
@@ -214,6 +235,27 @@ export function MessageContent({
|
||||
|
||||
if (segments.length === 0) return null
|
||||
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped'
|
||||
|
||||
let allLastGroupToolsDone = false
|
||||
if (lastSegment.type === 'agent_group') {
|
||||
const toolItems = lastSegment.items.filter((item) => item.type === 'tool')
|
||||
allLastGroupToolsDone =
|
||||
toolItems.length > 0 &&
|
||||
toolItems.every(
|
||||
(t) =>
|
||||
t.type === 'tool' &&
|
||||
(t.data.status === 'success' ||
|
||||
t.data.status === 'error' ||
|
||||
t.data.status === 'cancelled')
|
||||
)
|
||||
}
|
||||
|
||||
const hasSubagentEnded = blocks.some((b) => b.type === 'subagent_end')
|
||||
const showTrailingThinking =
|
||||
isStreaming && !hasTrailingContent && (hasSubagentEnded || allLastGroupToolsDone)
|
||||
|
||||
return (
|
||||
<div className='space-y-[10px]'>
|
||||
{segments.map((segment, i) => {
|
||||
@@ -270,6 +312,11 @@ export function MessageContent({
|
||||
)
|
||||
}
|
||||
})}
|
||||
{showTrailingThinking && (
|
||||
<div className='animate-stream-fade-in-delayed opacity-0'>
|
||||
<PendingTagIndicator />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
Asterisk,
|
||||
Blimp,
|
||||
BubbleChatPreview,
|
||||
Bug,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
Wrench,
|
||||
} from '@/components/emcn'
|
||||
import { Table as TableIcon } from '@/components/emcn/icons'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import type { MothershipToolName, SubagentName } from '../../types'
|
||||
|
||||
export type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
@@ -53,7 +53,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
|
||||
knowledge_base: Database,
|
||||
table: TableIcon,
|
||||
job: Calendar,
|
||||
agent: BubbleChatPreview,
|
||||
agent: AgentIcon,
|
||||
custom_tool: Wrench,
|
||||
research: Search,
|
||||
plan: ClipboardList,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user