mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
80 Commits
feat/paste
...
v0.6.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdd8ffb55 | ||
|
|
45f053a383 | ||
|
|
225d5d551a | ||
|
|
a78f3f9c2e | ||
|
|
080a0a6123 | ||
|
|
fc6fe193fa | ||
|
|
bbc704fe05 | ||
|
|
c016537564 | ||
|
|
4c94f3cf78 | ||
|
|
27a11a269d | ||
|
|
2c174ca4f6 | ||
|
|
ac831b85b2 | ||
|
|
8527ae5d3b | ||
|
|
076c835ba2 | ||
|
|
df6ceb61a4 | ||
|
|
2ede12aa0e | ||
|
|
42fb434354 | ||
|
|
d581009099 | ||
|
|
dcebe3ae97 | ||
|
|
e39c534ee3 | ||
|
|
b95a0491a0 | ||
|
|
a79c8a75ce | ||
|
|
282ec8c58c | ||
|
|
e45fbe0184 | ||
|
|
512558dcb3 | ||
|
|
35411e465e | ||
|
|
72e28baa07 | ||
|
|
d99dd86bf2 | ||
|
|
7898e5d75f | ||
|
|
df62502903 | ||
|
|
1a2aa6949e | ||
|
|
4544fd4519 | ||
|
|
019630bdc8 | ||
|
|
7d0fdefb22 | ||
|
|
90f592797a | ||
|
|
d091441e39 | ||
|
|
7d4dd26760 | ||
|
|
0abeac77e1 | ||
|
|
e9c94fa462 | ||
|
|
72eea64bf6 | ||
|
|
27460f847c | ||
|
|
c7643198dc | ||
|
|
e5aef6184a | ||
|
|
4ae5b1b620 | ||
|
|
5c334874eb | ||
|
|
d3d58a9615 | ||
|
|
1d59eca90a | ||
|
|
e1359b09d6 | ||
|
|
35b3646330 | ||
|
|
73e00f53e1 | ||
|
|
5c47ea58f8 | ||
|
|
1d7ae906bc | ||
|
|
c4f4e6b48c | ||
|
|
560fa75155 | ||
|
|
1728c370de | ||
|
|
82e58a5082 | ||
|
|
336c065234 | ||
|
|
b3713642b2 | ||
|
|
b9b930bb63 | ||
|
|
f1ead2ed55 | ||
|
|
30377d775b | ||
|
|
d013132d0e | ||
|
|
7b0ce8064a | ||
|
|
0ea73263df | ||
|
|
edc502384b | ||
|
|
e2be99263c | ||
|
|
f6b461ad47 | ||
|
|
e4d35735b1 | ||
|
|
b4064c57fb | ||
|
|
eac41ca105 | ||
|
|
d2c3c1c39e | ||
|
|
8f3e864751 | ||
|
|
23c3072784 | ||
|
|
33fdb11396 | ||
|
|
21156dd54a | ||
|
|
c05e2e0fc8 | ||
|
|
a7c1e510e6 | ||
|
|
271624a402 | ||
|
|
dda012eae9 | ||
|
|
2dd6d3d1e6 |
@@ -192,7 +192,7 @@ In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` t
|
||||
},
|
||||
```
|
||||
|
||||
The visibility is controlled by `isSubBlockHiddenByHostedKey()` in `lib/workflows/subblocks/visibility.ts`, which checks the `isHosted` feature flag.
|
||||
The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`).
|
||||
|
||||
### Excluding Specific Operations from Hosted Key Support
|
||||
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
branches: [main, staging, dev]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
branches: [main, staging, dev]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
detect-version:
|
||||
name: Detect Version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
|
||||
outputs:
|
||||
version: ${{ steps.extract.outputs.version }}
|
||||
is_release: ${{ steps.extract.outputs.is_release }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
build-amd64:
|
||||
name: Build AMD64
|
||||
needs: [test-build, detect-version]
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -75,8 +75,8 @@ jobs:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
|
||||
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
@@ -109,6 +109,8 @@ jobs:
|
||||
# ECR tags (always build for ECR)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
ECR_TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
ECR_TAG="dev"
|
||||
else
|
||||
ECR_TAG="staging"
|
||||
fi
|
||||
|
||||
6
.github/workflows/images.yml
vendored
6
.github/workflows/images.yml
vendored
@@ -36,8 +36,8 @@ jobs:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
|
||||
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
@@ -70,6 +70,8 @@ jobs:
|
||||
# ECR tags (always build for ECR)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
ECR_TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
ECR_TAG="dev"
|
||||
else
|
||||
ECR_TAG="staging"
|
||||
fi
|
||||
|
||||
10
README.md
10
README.md
@@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
#### Background worker note
|
||||
|
||||
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
|
||||
|
||||
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
|
||||
|
||||
### Self-hosted: Manual Setup
|
||||
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
5. Start development servers:
|
||||
|
||||
```bash
|
||||
bun run dev:full # Starts both Next.js app and realtime socket server
|
||||
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
|
||||
```
|
||||
|
||||
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
|
||||
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
|
||||
|
||||
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
|
||||
|
||||
## Copilot API Keys
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
template: '%s',
|
||||
template: '%s | 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.',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -45,6 +45,7 @@ import {
|
||||
EnrichSoIcon,
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
ExtendIcon,
|
||||
EyeIcon,
|
||||
FathomIcon,
|
||||
FirecrawlIcon,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
KalshiIcon,
|
||||
KetchIcon,
|
||||
LangsmithIcon,
|
||||
LaunchDarklyIcon,
|
||||
LemlistIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
@@ -126,6 +128,7 @@ import {
|
||||
PolymarketIcon,
|
||||
PostgresIcon,
|
||||
PosthogIcon,
|
||||
ProfoundIcon,
|
||||
PulseIcon,
|
||||
QdrantIcon,
|
||||
QuiverIcon,
|
||||
@@ -136,9 +139,11 @@ import {
|
||||
ResendIcon,
|
||||
RevenueCatIcon,
|
||||
RipplingIcon,
|
||||
RootlyIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
SearchIcon,
|
||||
SecretsManagerIcon,
|
||||
SendgridIcon,
|
||||
SentryIcon,
|
||||
SerperIcon,
|
||||
@@ -154,6 +159,7 @@ import {
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TailscaleIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
@@ -220,6 +226,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
enrich: EnrichSoIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
extend_v2: ExtendIcon,
|
||||
fathom: FathomIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
@@ -268,6 +275,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
ketch: KetchIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
langsmith: LangsmithIcon,
|
||||
launchdarkly: LaunchDarklyIcon,
|
||||
lemlist: LemlistIcon,
|
||||
linear: LinearIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
@@ -302,6 +310,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
profound: ProfoundIcon,
|
||||
pulse_v2: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
quiver: QuiverIcon,
|
||||
@@ -312,9 +321,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
resend: ResendIcon,
|
||||
revenuecat: RevenueCatIcon,
|
||||
rippling: RipplingIcon,
|
||||
rootly: RootlyIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
search: SearchIcon,
|
||||
secrets_manager: SecretsManagerIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
@@ -331,6 +342,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
stripe: StripeIcon,
|
||||
stt_v2: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
tailscale: TailscaleIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
textract_v2: TextractIcon,
|
||||
|
||||
@@ -131,7 +131,7 @@ Erkennt personenbezogene Daten mithilfe von Microsoft Presidio. Unterstützt üb
|
||||
**Anwendungsfälle:**
|
||||
- Blockieren von Inhalten mit sensiblen persönlichen Informationen
|
||||
- Maskieren von personenbezogenen Daten vor der Protokollierung oder Speicherung
|
||||
- Einhaltung der DSGVO, HIPAA und anderer Datenschutzbestimmungen
|
||||
- Einhaltung der DSGVO und anderer Datenschutzbestimmungen
|
||||
- Bereinigung von Benutzereingaben vor der Verarbeitung
|
||||
|
||||
## Konfiguration
|
||||
|
||||
@@ -132,7 +132,7 @@ Detects personally identifiable information using Microsoft Presidio. Supports o
|
||||
**Use Cases:**
|
||||
- Block content containing sensitive personal information
|
||||
- Mask PII before logging or storing data
|
||||
- Compliance with GDPR, HIPAA, and other privacy regulations
|
||||
- Compliance with GDPR and other privacy regulations
|
||||
- Sanitize user inputs before processing
|
||||
|
||||
## Configuration
|
||||
|
||||
206
apps/docs/content/docs/en/credentials/google-service-account.mdx
Normal file
206
apps/docs/content/docs/en/credentials/google-service-account.mdx
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
title: Google Service Accounts
|
||||
description: Set up Google service accounts with domain-wide delegation for Gmail, Sheets, Drive, Calendar, and other Google services
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Google service accounts with domain-wide delegation let your workflows access Google APIs on behalf of users in your Google Workspace domain — without requiring each user to complete an OAuth consent flow. This is ideal for automated workflows that need to send emails, read spreadsheets, or manage files across your organization.
|
||||
|
||||
For example, you could build a workflow that iterates through a list of employees, impersonates each one to read their Google Docs, and uploads the contents to a shared knowledge base — all without requiring any of those users to sign in.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before adding a service account to Sim, you need to configure it in the Google Cloud Console and Google Workspace Admin Console.
|
||||
|
||||
### 1. Create a Service Account in Google Cloud
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one)
|
||||
</Step>
|
||||
<Step>
|
||||
Navigate to **IAM & Admin** → **Service Accounts**
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Create Service Account**, give it a name and description, then click **Create and Continue**
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-create-service-account.png"
|
||||
alt="Google Cloud Console — Create service account form"
|
||||
width={700}
|
||||
height={500}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Skip the optional role and user access steps and click **Done**
|
||||
</Step>
|
||||
<Step>
|
||||
Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key**
|
||||
</Step>
|
||||
<Step>
|
||||
Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-create-private-key.png"
|
||||
alt="Google Cloud Console — Create private key dialog with JSON selected"
|
||||
width={700}
|
||||
height={400}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="warn">
|
||||
The JSON key file contains your service account's private key. Treat it like a password — do not commit it to source control or share it publicly.
|
||||
</Callout>
|
||||
|
||||
### 2. Enable the Required APIs
|
||||
|
||||
In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the APIs for the services your workflows will use. See the [scopes reference](#scopes-reference) below for the full list of APIs by service.
|
||||
|
||||
### 3. Set Up Domain-Wide Delegation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
In the Google Cloud Console, go to **IAM & Admin** → **Service Accounts**, click on your service account, and copy the **Client ID** (the numeric ID, not the email)
|
||||
</Step>
|
||||
<Step>
|
||||
Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls**
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Manage Domain Wide Delegation**, then click **Add new**
|
||||
</Step>
|
||||
<Step>
|
||||
Paste the **Client ID** from your service account, then add the OAuth scopes for the services your workflows need. Copy the full scope URLs from the [scopes reference](#scopes-reference) below — only authorize scopes for services you plan to use.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-add-client-id.png"
|
||||
alt="Google Workspace Admin Console — Add a new client ID with OAuth scopes"
|
||||
width={350}
|
||||
height={300}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Authorize**
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Domain-wide delegation must be configured by a Google Workspace admin. If you are not an admin, send the Client ID and required scopes to your admin.
|
||||
</Callout>
|
||||
|
||||
### Scopes Reference
|
||||
|
||||
The table below lists every Google service that supports service account authentication in Sim, the API to enable in Google Cloud Console, and the delegation scopes to authorize. Copy the scope string for each service you need and paste it into the Google Workspace Admin Console.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="whitespace-nowrap">Service</th>
|
||||
<th className="whitespace-nowrap">API to Enable</th>
|
||||
<th>Delegation Scopes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Gmail</td><td>Gmail API</td><td><code>{'https://www.googleapis.com/auth/gmail.send'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.modify'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.labels'}</code></td></tr>
|
||||
<tr><td>Google Sheets</td><td>Google Sheets API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Drive</td><td>Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Docs</td><td>Google Docs API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Slides</td><td>Google Slides API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Forms</td><td>Google Forms API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/forms.body'}</code><br/><code>{'https://www.googleapis.com/auth/forms.responses.readonly'}</code></td></tr>
|
||||
<tr><td>Google Calendar</td><td>Google Calendar API</td><td><code>{'https://www.googleapis.com/auth/calendar'}</code></td></tr>
|
||||
<tr><td>Google Contacts</td><td>People API</td><td><code>{'https://www.googleapis.com/auth/contacts'}</code></td></tr>
|
||||
<tr><td>BigQuery</td><td>BigQuery API</td><td><code>{'https://www.googleapis.com/auth/bigquery'}</code></td></tr>
|
||||
<tr><td>Google Tasks</td><td>Tasks API</td><td><code>{'https://www.googleapis.com/auth/tasks'}</code></td></tr>
|
||||
<tr><td>Google Vault</td><td>Vault API, Cloud Storage API</td><td><code>{'https://www.googleapis.com/auth/ediscovery'}</code><br/><code>{'https://www.googleapis.com/auth/devstorage.read_only'}</code></td></tr>
|
||||
<tr><td>Google Groups</td><td>Admin SDK API</td><td><code>{'https://www.googleapis.com/auth/admin.directory.group'}</code><br/><code>{'https://www.googleapis.com/auth/admin.directory.group.member'}</code></td></tr>
|
||||
<tr><td>Google Meet</td><td>Google Meet API</td><td><code>{'https://www.googleapis.com/auth/meetings.space.created'}</code><br/><code>{'https://www.googleapis.com/auth/meetings.space.readonly'}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Callout type="info">
|
||||
You only need to enable APIs and authorize scopes for the services you plan to use. When authorizing multiple services, combine their scope strings with commas into a single entry in the Admin Console.
|
||||
</Callout>
|
||||
|
||||
## Adding the Service Account to Sim
|
||||
|
||||
Once Google Cloud and Workspace are configured, add the service account as a credential in Sim.
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Open your workspace **Settings** and go to the **Integrations** tab
|
||||
</Step>
|
||||
<Step>
|
||||
Search for "Google Service Account" and click **Connect**
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/integrations-service-account.png"
|
||||
alt="Integrations page showing Google Service Account"
|
||||
width={800}
|
||||
height={150}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Paste the full contents of your JSON key file into the text area
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/add-service-account.png"
|
||||
alt="Add Google Service Account dialog"
|
||||
width={350}
|
||||
height={420}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Give the credential a display name (the service account email is used by default)
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Save**
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The JSON key file is validated for the required fields (`type`, `client_email`, `private_key`, `project_id`) and encrypted before being stored.
|
||||
|
||||
## Using Delegated Access in Workflows
|
||||
|
||||
When you use a Google block (Gmail, Sheets, Drive, etc.) in a workflow and select a service account credential, an **Impersonate User Email** field appears below the credential selector.
|
||||
|
||||
Enter the email address of the Google Workspace user you want the service account to act as. For example, if you enter `alice@yourcompany.com`, the workflow will send emails from Alice's account, read her spreadsheets, or access her calendar — depending on the scopes you authorized.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/workflow-impersonated-account.png"
|
||||
alt="Gmail block in a workflow showing the Impersonated Account field with a service account credential"
|
||||
width={800}
|
||||
height={350}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout type="warn">
|
||||
The impersonated email must belong to a user in the Google Workspace domain where you configured domain-wide delegation. Impersonating external email addresses will fail.
|
||||
</Callout>
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Can I use a service account without domain-wide delegation?", answer: "Yes, but it will only be able to access resources owned by the service account itself (e.g., spreadsheets shared directly with the service account email). Without delegation, you cannot impersonate users or access their personal data like Gmail." },
|
||||
{ question: "What happens if the impersonation email field is left blank?", answer: "The service account will authenticate as itself. This works for accessing shared resources (like a Google Sheet shared with the service account email) but will fail for user-specific APIs like Gmail." },
|
||||
{ question: "Can I use the same service account for multiple Google services?", answer: "Yes. A single service account can be used across Gmail, Sheets, Drive, Calendar, and other Google services — as long as the required API is enabled in Google Cloud and the corresponding scopes are authorized in the Workspace admin console." },
|
||||
{ question: "How do I rotate the service account key?", answer: "Create a new JSON key in the Google Cloud Console under your service account's Keys tab, then update the credential in Sim with the new key. Delete the old key from Google Cloud once the new one is working." },
|
||||
{ question: "Does the impersonated user need a Google Workspace license?", answer: "Yes. Domain-wide delegation only works with users who have a Google Workspace account in the domain. Consumer Gmail accounts (e.g., @gmail.com) cannot be impersonated." },
|
||||
]} />
|
||||
5
apps/docs/content/docs/en/credentials/meta.json
Normal file
5
apps/docs/content/docs/en/credentials/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Credentials",
|
||||
"pages": ["index", "google-service-account"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
@@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow
|
||||
|
||||
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
|
||||
|
||||
### Concurrent Execution Limits
|
||||
|
||||
| Plan | Concurrent Executions |
|
||||
|------|----------------------|
|
||||
| **Free** | 5 |
|
||||
| **Pro** | 50 |
|
||||
| **Max / Team** | 200 |
|
||||
| **Enterprise** | 200 (customizable) |
|
||||
|
||||
Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits.
|
||||
|
||||
### File Storage
|
||||
|
||||
| Plan | Storage |
|
||||
|
||||
@@ -359,6 +359,35 @@ List tasks in Attio, optionally filtered by record, assignee, or completion stat
|
||||
| ↳ `createdAt` | string | When the task was created |
|
||||
| `count` | number | Number of tasks returned |
|
||||
|
||||
### `attio_get_task`
|
||||
|
||||
Get a single task by ID from Attio
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `taskId` | string | Yes | The ID of the task to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `taskId` | string | The task ID |
|
||||
| `content` | string | The task content |
|
||||
| `deadlineAt` | string | The task deadline |
|
||||
| `isCompleted` | boolean | Whether the task is completed |
|
||||
| `linkedRecords` | array | Records linked to this task |
|
||||
| ↳ `targetObjectId` | string | The linked object ID |
|
||||
| ↳ `targetRecordId` | string | The linked record ID |
|
||||
| `assignees` | array | Task assignees |
|
||||
| ↳ `type` | string | The assignee actor type \(e.g. workspace-member\) |
|
||||
| ↳ `id` | string | The assignee actor ID |
|
||||
| `createdByActor` | object | The actor who created this task |
|
||||
| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) |
|
||||
| ↳ `id` | string | The actor ID |
|
||||
| `createdAt` | string | When the task was created |
|
||||
|
||||
### `attio_create_task`
|
||||
|
||||
Create a task in Attio
|
||||
@@ -1012,8 +1041,8 @@ Update a webhook in Attio (target URL and/or subscriptions)
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `webhookId` | string | Yes | The webhook ID to update |
|
||||
| `targetUrl` | string | Yes | HTTPS target URL for webhook delivery |
|
||||
| `subscriptions` | string | Yes | JSON array of subscriptions, e.g. \[\{"event_type":"note.created"\}\] |
|
||||
| `targetUrl` | string | No | HTTPS target URL for webhook delivery |
|
||||
| `subscriptions` | string | No | JSON array of subscriptions, e.g. \[\{"event_type":"note.created"\}\] |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
39
apps/docs/content/docs/en/tools/extend.mdx
Normal file
39
apps/docs/content/docs/en/tools/extend.mdx
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Extend
|
||||
description: Parse and extract content from documents
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="extend_v2"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Extend AI into the workflow. Parse and extract structured content from documents or file references.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `extend_parser`
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `filePath` | string | No | URL to a document to be processed |
|
||||
| `file` | file | No | Document file to be processed |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `outputFormat` | string | No | Target output format \(markdown or spatial\). Defaults to markdown. |
|
||||
| `chunking` | string | No | Chunking strategy \(page, document, or section\). Defaults to page. |
|
||||
| `engine` | string | No | Parsing engine \(parse_performance or parse_light\). Defaults to parse_performance. |
|
||||
| `apiKey` | string | Yes | Extend API key |
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: File
|
||||
description: Read and parse multiple files
|
||||
description: Read and write workspace files
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -27,7 +27,7 @@ The File Parser tool is particularly useful for scenarios where your agents need
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Upload files directly or import from external URLs to get UserFile objects for use in other blocks.
|
||||
Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.
|
||||
|
||||
|
||||
|
||||
@@ -52,4 +52,45 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
|
||||
| `files` | file[] | Parsed files as UserFile objects |
|
||||
| `combinedContent` | string | Combined content of all parsed files |
|
||||
|
||||
### `file_write`
|
||||
|
||||
Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g.,
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | File name \(e.g., "data.csv"\). If a file with this name exists, a numeric suffix is added automatically. |
|
||||
| `content` | string | Yes | The text content to write to the file. |
|
||||
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from file extension if omitted. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | File ID |
|
||||
| `name` | string | File name |
|
||||
| `size` | number | File size in bytes |
|
||||
| `url` | string | URL to access the file |
|
||||
|
||||
### `file_append`
|
||||
|
||||
Append content to an existing workspace file. The file must already exist. Content is added to the end of the file.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | Name of an existing workspace file to append to. |
|
||||
| `content` | string | Yes | The text content to append to the file. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | File ID |
|
||||
| `name` | string | File name |
|
||||
| `size` | number | File size in bytes |
|
||||
| `url` | string | URL to access the file |
|
||||
|
||||
|
||||
|
||||
388
apps/docs/content/docs/en/tools/launchdarkly.mdx
Normal file
388
apps/docs/content/docs/en/tools/launchdarkly.mdx
Normal file
@@ -0,0 +1,388 @@
|
||||
---
|
||||
title: LaunchDarkly
|
||||
description: Manage feature flags with LaunchDarkly.
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="launchdarkly"
|
||||
color="#191919"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[LaunchDarkly](https://launchdarkly.com/) is a feature management platform that enables teams to safely deploy, control, and measure their software features at scale.
|
||||
|
||||
With the LaunchDarkly integration in Sim, you can:
|
||||
|
||||
- **Feature flag management** — List, create, update, toggle, and delete feature flags programmatically. Toggle flags on or off in specific environments using LaunchDarkly's semantic patch API.
|
||||
- **Flag status monitoring** — Check whether a flag is active, inactive, new, or launched in a given environment. Track the last time a flag was evaluated.
|
||||
- **Project and environment management** — List all projects and their environments to understand your LaunchDarkly organization structure.
|
||||
- **User segments** — List user segments within a project and environment to understand how your audience is organized for targeting.
|
||||
- **Team visibility** — List account members and their roles for auditing and access management workflows.
|
||||
- **Audit log** — Retrieve recent audit log entries to track who changed what, when. Filter entries by resource type for targeted monitoring.
|
||||
|
||||
In Sim, the LaunchDarkly integration enables your agents to automate feature flag operations as part of their workflows. This allows for automation scenarios such as toggling flags on/off based on deployment pipeline events, monitoring flag status and alerting on stale or unused flags, auditing flag changes by querying the audit log after deployments, syncing flag metadata with your project management tools, and listing all feature flags across projects for governance.
|
||||
|
||||
## Authentication
|
||||
|
||||
This integration uses a LaunchDarkly API key. You can create personal access tokens or service tokens in the LaunchDarkly dashboard under **Account Settings > Authorization**. The API key is passed directly in the `Authorization` header (no `Bearer` prefix).
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues with the LaunchDarkly integration, contact us at [help@sim.ai](mailto:help@sim.ai)
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate LaunchDarkly into your workflow. List, create, update, toggle, and delete feature flags. Manage projects, environments, segments, members, and audit logs. Requires API Key.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `launchdarkly_create_flag`
|
||||
|
||||
Create a new feature flag in a LaunchDarkly project.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key to create the flag in |
|
||||
| `name` | string | Yes | Human-readable name for the feature flag |
|
||||
| `key` | string | Yes | Unique key for the feature flag \(used in code\) |
|
||||
| `description` | string | No | Description of the feature flag |
|
||||
| `tags` | string | No | Comma-separated list of tags |
|
||||
| `temporary` | boolean | No | Whether the flag is temporary \(default true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `key` | string | The unique key of the feature flag |
|
||||
| `name` | string | The human-readable name of the feature flag |
|
||||
| `kind` | string | The type of flag \(boolean or multivariate\) |
|
||||
| `description` | string | Description of the feature flag |
|
||||
| `temporary` | boolean | Whether the flag is temporary |
|
||||
| `archived` | boolean | Whether the flag is archived |
|
||||
| `deprecated` | boolean | Whether the flag is deprecated |
|
||||
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
|
||||
| `tags` | array | Tags applied to the flag |
|
||||
| `variations` | array | The variations for this feature flag |
|
||||
| ↳ `value` | string | The variation value |
|
||||
| ↳ `name` | string | The variation name |
|
||||
| ↳ `description` | string | The variation description |
|
||||
| `maintainerId` | string | The ID of the member who maintains this flag |
|
||||
|
||||
### `launchdarkly_delete_flag`
|
||||
|
||||
Delete a feature flag from a LaunchDarkly project.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key |
|
||||
| `flagKey` | string | Yes | The feature flag key to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the flag was successfully deleted |
|
||||
|
||||
### `launchdarkly_get_audit_log`
|
||||
|
||||
List audit log entries from your LaunchDarkly account.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `limit` | number | No | Maximum number of entries to return \(default 10, max 20\) |
|
||||
| `spec` | string | No | Filter expression \(e.g., "resourceType:flag"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `entries` | array | List of audit log entries |
|
||||
| ↳ `id` | string | The audit log entry ID |
|
||||
| ↳ `date` | number | Unix timestamp in milliseconds |
|
||||
| ↳ `kind` | string | The type of action performed |
|
||||
| ↳ `name` | string | The name of the resource acted on |
|
||||
| ↳ `description` | string | Full description of the action |
|
||||
| ↳ `shortDescription` | string | Short description of the action |
|
||||
| ↳ `memberEmail` | string | Email of the member who performed the action |
|
||||
| ↳ `targetName` | string | Name of the target resource |
|
||||
| ↳ `targetKind` | string | Kind of the target resource |
|
||||
| `totalCount` | number | Total number of audit log entries |
|
||||
|
||||
### `launchdarkly_get_flag`
|
||||
|
||||
Get a single feature flag by key from a LaunchDarkly project.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key |
|
||||
| `flagKey` | string | Yes | The feature flag key |
|
||||
| `environmentKey` | string | No | Filter flag configuration to a specific environment |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `key` | string | The unique key of the feature flag |
|
||||
| `name` | string | The human-readable name of the feature flag |
|
||||
| `kind` | string | The type of flag \(boolean or multivariate\) |
|
||||
| `description` | string | Description of the feature flag |
|
||||
| `temporary` | boolean | Whether the flag is temporary |
|
||||
| `archived` | boolean | Whether the flag is archived |
|
||||
| `deprecated` | boolean | Whether the flag is deprecated |
|
||||
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
|
||||
| `tags` | array | Tags applied to the flag |
|
||||
| `variations` | array | The variations for this feature flag |
|
||||
| ↳ `value` | string | The variation value |
|
||||
| ↳ `name` | string | The variation name |
|
||||
| ↳ `description` | string | The variation description |
|
||||
| `maintainerId` | string | The ID of the member who maintains this flag |
|
||||
| `on` | boolean | Whether the flag is on in the requested environment \(null if no single environment was specified\) |
|
||||
|
||||
### `launchdarkly_get_flag_status`
|
||||
|
||||
Get the status of a feature flag across environments (active, inactive, launched, etc.).
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key |
|
||||
| `flagKey` | string | Yes | The feature flag key |
|
||||
| `environmentKey` | string | Yes | The environment key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `name` | string | The flag status \(new, active, inactive, launched\) |
|
||||
| `lastRequested` | string | Timestamp of the last evaluation |
|
||||
| `defaultVal` | string | The default variation value |
|
||||
|
||||
### `launchdarkly_list_environments`
|
||||
|
||||
List environments in a LaunchDarkly project.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key to list environments for |
|
||||
| `limit` | number | No | Maximum number of environments to return \(default 20\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `environments` | array | List of environments |
|
||||
| ↳ `id` | string | The environment ID |
|
||||
| ↳ `key` | string | The unique environment key |
|
||||
| ↳ `name` | string | The environment name |
|
||||
| ↳ `color` | string | The color assigned to this environment |
|
||||
| ↳ `apiKey` | string | The server-side SDK key for this environment |
|
||||
| ↳ `mobileKey` | string | The mobile SDK key for this environment |
|
||||
| ↳ `tags` | array | Tags applied to the environment |
|
||||
| `totalCount` | number | Total number of environments |
|
||||
|
||||
### `launchdarkly_list_flags`
|
||||
|
||||
List feature flags in a LaunchDarkly project.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key to list flags for |
|
||||
| `environmentKey` | string | No | Filter flag configurations to a specific environment |
|
||||
| `tag` | string | No | Filter flags by tag name |
|
||||
| `limit` | number | No | Maximum number of flags to return \(default 20\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `flags` | array | List of feature flags |
|
||||
| ↳ `key` | string | The unique key of the feature flag |
|
||||
| ↳ `name` | string | The human-readable name of the feature flag |
|
||||
| ↳ `kind` | string | The type of flag \(boolean or multivariate\) |
|
||||
| ↳ `description` | string | Description of the feature flag |
|
||||
| ↳ `temporary` | boolean | Whether the flag is temporary |
|
||||
| ↳ `archived` | boolean | Whether the flag is archived |
|
||||
| ↳ `deprecated` | boolean | Whether the flag is deprecated |
|
||||
| ↳ `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
|
||||
| ↳ `tags` | array | Tags applied to the flag |
|
||||
| ↳ `variations` | array | The variations for this feature flag |
|
||||
| ↳ `value` | string | The variation value |
|
||||
| ↳ `name` | string | The variation name |
|
||||
| ↳ `description` | string | The variation description |
|
||||
| ↳ `maintainerId` | string | The ID of the member who maintains this flag |
|
||||
| `totalCount` | number | Total number of flags |
|
||||
|
||||
### `launchdarkly_list_members`
|
||||
|
||||
List account members in your LaunchDarkly organization.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `limit` | number | No | Maximum number of members to return \(default 20\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `members` | array | List of account members |
|
||||
| ↳ `id` | string | The member ID |
|
||||
| ↳ `email` | string | The member email address |
|
||||
| ↳ `firstName` | string | The member first name |
|
||||
| ↳ `lastName` | string | The member last name |
|
||||
| ↳ `role` | string | The member role \(reader, writer, admin, owner\) |
|
||||
| ↳ `lastSeen` | number | Unix timestamp of last activity |
|
||||
| ↳ `creationDate` | number | Unix timestamp when the member was created |
|
||||
| ↳ `verified` | boolean | Whether the member email is verified |
|
||||
| `totalCount` | number | Total number of members |
|
||||
|
||||
### `launchdarkly_list_projects`
|
||||
|
||||
List all projects in your LaunchDarkly account.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `limit` | number | No | Maximum number of projects to return \(default 20\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `projects` | array | List of projects |
|
||||
| ↳ `id` | string | The project ID |
|
||||
| ↳ `key` | string | The unique project key |
|
||||
| ↳ `name` | string | The project name |
|
||||
| ↳ `tags` | array | Tags applied to the project |
|
||||
| `totalCount` | number | Total number of projects |
|
||||
|
||||
### `launchdarkly_list_segments`
|
||||
|
||||
List user segments in a LaunchDarkly project and environment.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key |
|
||||
| `environmentKey` | string | Yes | The environment key |
|
||||
| `limit` | number | No | Maximum number of segments to return \(default 20\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `segments` | array | List of user segments |
|
||||
| ↳ `key` | string | The unique segment key |
|
||||
| ↳ `name` | string | The segment name |
|
||||
| ↳ `description` | string | The segment description |
|
||||
| ↳ `tags` | array | Tags applied to the segment |
|
||||
| ↳ `creationDate` | number | Unix timestamp in milliseconds when the segment was created |
|
||||
| ↳ `unbounded` | boolean | Whether this is an unbounded \(big\) segment |
|
||||
| ↳ `included` | array | User keys explicitly included in the segment |
|
||||
| ↳ `excluded` | array | User keys explicitly excluded from the segment |
|
||||
| `totalCount` | number | Total number of segments |
|
||||
|
||||
### `launchdarkly_toggle_flag`
|
||||
|
||||
Toggle a feature flag on or off in a specific LaunchDarkly environment.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key |
|
||||
| `flagKey` | string | Yes | The feature flag key to toggle |
|
||||
| `environmentKey` | string | Yes | The environment key to toggle the flag in |
|
||||
| `enabled` | boolean | Yes | Whether to turn the flag on \(true\) or off \(false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `key` | string | The unique key of the feature flag |
|
||||
| `name` | string | The human-readable name of the feature flag |
|
||||
| `kind` | string | The type of flag \(boolean or multivariate\) |
|
||||
| `description` | string | Description of the feature flag |
|
||||
| `temporary` | boolean | Whether the flag is temporary |
|
||||
| `archived` | boolean | Whether the flag is archived |
|
||||
| `deprecated` | boolean | Whether the flag is deprecated |
|
||||
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
|
||||
| `tags` | array | Tags applied to the flag |
|
||||
| `variations` | array | The variations for this feature flag |
|
||||
| ↳ `value` | string | The variation value |
|
||||
| ↳ `name` | string | The variation name |
|
||||
| ↳ `description` | string | The variation description |
|
||||
| `maintainerId` | string | The ID of the member who maintains this flag |
|
||||
| `on` | boolean | Whether the flag is now on in the target environment |
|
||||
|
||||
### `launchdarkly_update_flag`
|
||||
|
||||
Update a feature flag metadata (name, description, tags, temporary, archived) using semantic patch.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | LaunchDarkly API key |
|
||||
| `projectKey` | string | Yes | The project key |
|
||||
| `flagKey` | string | Yes | The feature flag key to update |
|
||||
| `updateName` | string | No | New name for the flag |
|
||||
| `updateDescription` | string | No | New description for the flag |
|
||||
| `addTags` | string | No | Comma-separated tags to add |
|
||||
| `removeTags` | string | No | Comma-separated tags to remove |
|
||||
| `archive` | boolean | No | Set to true to archive, false to restore |
|
||||
| `comment` | string | No | Optional comment explaining the update |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `key` | string | The unique key of the feature flag |
|
||||
| `name` | string | The human-readable name of the feature flag |
|
||||
| `kind` | string | The type of flag \(boolean or multivariate\) |
|
||||
| `description` | string | Description of the feature flag |
|
||||
| `temporary` | boolean | Whether the flag is temporary |
|
||||
| `archived` | boolean | Whether the flag is archived |
|
||||
| `deprecated` | boolean | Whether the flag is deprecated |
|
||||
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
|
||||
| `tags` | array | Tags applied to the flag |
|
||||
| `variations` | array | The variations for this feature flag |
|
||||
| ↳ `value` | string | The variation value |
|
||||
| ↳ `name` | string | The variation name |
|
||||
| ↳ `description` | string | The variation description |
|
||||
| `maintainerId` | string | The ID of the member who maintains this flag |
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"enrich",
|
||||
"evernote",
|
||||
"exa",
|
||||
"extend",
|
||||
"fathom",
|
||||
"file",
|
||||
"firecrawl",
|
||||
@@ -87,6 +88,7 @@
|
||||
"ketch",
|
||||
"knowledge",
|
||||
"langsmith",
|
||||
"launchdarkly",
|
||||
"lemlist",
|
||||
"linear",
|
||||
"linkedin",
|
||||
@@ -121,6 +123,7 @@
|
||||
"polymarket",
|
||||
"postgresql",
|
||||
"posthog",
|
||||
"profound",
|
||||
"pulse",
|
||||
"qdrant",
|
||||
"quiver",
|
||||
@@ -131,9 +134,11 @@
|
||||
"resend",
|
||||
"revenuecat",
|
||||
"rippling",
|
||||
"rootly",
|
||||
"s3",
|
||||
"salesforce",
|
||||
"search",
|
||||
"secrets_manager",
|
||||
"sendgrid",
|
||||
"sentry",
|
||||
"serper",
|
||||
@@ -151,6 +156,7 @@
|
||||
"stt",
|
||||
"supabase",
|
||||
"table",
|
||||
"tailscale",
|
||||
"tavily",
|
||||
"telegram",
|
||||
"textract",
|
||||
|
||||
626
apps/docs/content/docs/en/tools/profound.mdx
Normal file
626
apps/docs/content/docs/en/tools/profound.mdx
Normal file
@@ -0,0 +1,626 @@
|
||||
---
|
||||
title: Profound
|
||||
description: AI visibility and analytics with Profound
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="profound"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Profound](https://tryprofound.com/) is an AI visibility and analytics platform that helps brands understand how they appear across AI-powered search engines, chatbots, and assistants. It tracks mentions, citations, sentiment, bot traffic, and referral patterns across platforms like ChatGPT, Perplexity, Google AI Overviews, and more.
|
||||
|
||||
With the Profound integration in Sim, you can:
|
||||
|
||||
- **Monitor AI Visibility**: Track share of voice, visibility scores, and mention counts across AI platforms for your brand and competitors.
|
||||
- **Analyze Sentiment**: Measure how positively or negatively your brand is discussed in AI-generated responses.
|
||||
- **Track Citations**: See which URLs are being cited by AI models and your citation share relative to competitors.
|
||||
- **Monitor Bot Traffic**: Analyze AI crawler activity on your domain, including GPTBot, ClaudeBot, and other AI agents, with hourly granularity.
|
||||
- **Track Referral Traffic**: Monitor human visits arriving from AI platforms to your website.
|
||||
- **Explore Prompt Data**: Access raw prompt-answer pairs, query fanouts, and prompt volume trends across AI platforms.
|
||||
- **Optimize Content**: Get AEO (Answer Engine Optimization) scores and actionable recommendations to improve how AI models reference your content.
|
||||
- **Manage Categories & Assets**: List and explore your tracked categories, assets (brands), topics, tags, personas, and regions.
|
||||
|
||||
These tools let your agents automate AI visibility monitoring, competitive intelligence, and content optimization workflows. To use the Profound integration, you'll need a Profound account with API access.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `profound_list_categories`
|
||||
|
||||
List all organization categories in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `categories` | json | List of organization categories |
|
||||
| ↳ `id` | string | Category ID |
|
||||
| ↳ `name` | string | Category name |
|
||||
|
||||
### `profound_list_regions`
|
||||
|
||||
List all organization regions in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `regions` | json | List of organization regions |
|
||||
| ↳ `id` | string | Region ID \(UUID\) |
|
||||
| ↳ `name` | string | Region name |
|
||||
|
||||
### `profound_list_models`
|
||||
|
||||
List all AI models/platforms tracked in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `models` | json | List of AI models/platforms |
|
||||
| ↳ `id` | string | Model ID \(UUID\) |
|
||||
| ↳ `name` | string | Model/platform name |
|
||||
|
||||
### `profound_list_domains`
|
||||
|
||||
List all organization domains in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `domains` | json | List of organization domains |
|
||||
| ↳ `id` | string | Domain ID \(UUID\) |
|
||||
| ↳ `name` | string | Domain name |
|
||||
| ↳ `createdAt` | string | When the domain was added |
|
||||
|
||||
### `profound_list_assets`
|
||||
|
||||
List all organization assets (companies/brands) across all categories in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `assets` | json | List of organization assets with category info |
|
||||
| ↳ `id` | string | Asset ID |
|
||||
| ↳ `name` | string | Asset/company name |
|
||||
| ↳ `website` | string | Asset website URL |
|
||||
| ↳ `alternateDomains` | json | Alternate domain names |
|
||||
| ↳ `isOwned` | boolean | Whether this asset is owned by the organization |
|
||||
| ↳ `createdAt` | string | When the asset was created |
|
||||
| ↳ `logoUrl` | string | URL of the asset logo |
|
||||
| ↳ `categoryId` | string | Category ID the asset belongs to |
|
||||
| ↳ `categoryName` | string | Category name |
|
||||
|
||||
### `profound_list_personas`
|
||||
|
||||
List all organization personas across all categories in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `personas` | json | List of organization personas with profile details |
|
||||
| ↳ `id` | string | Persona ID |
|
||||
| ↳ `name` | string | Persona name |
|
||||
| ↳ `categoryId` | string | Category ID |
|
||||
| ↳ `categoryName` | string | Category name |
|
||||
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
|
||||
|
||||
### `profound_category_topics`
|
||||
|
||||
List topics for a specific category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `topics` | json | List of topics in the category |
|
||||
| ↳ `id` | string | Topic ID \(UUID\) |
|
||||
| ↳ `name` | string | Topic name |
|
||||
|
||||
### `profound_category_tags`
|
||||
|
||||
List tags for a specific category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tags` | json | List of tags in the category |
|
||||
| ↳ `id` | string | Tag ID \(UUID\) |
|
||||
| ↳ `name` | string | Tag name |
|
||||
|
||||
### `profound_category_prompts`
|
||||
|
||||
List prompts for a specific category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 10000\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `orderDir` | string | No | Sort direction: asc or desc \(default desc\) |
|
||||
| `promptType` | string | No | Comma-separated prompt types to filter: visibility, sentiment |
|
||||
| `topicId` | string | No | Comma-separated topic IDs \(UUIDs\) to filter by |
|
||||
| `tagId` | string | No | Comma-separated tag IDs \(UUIDs\) to filter by |
|
||||
| `regionId` | string | No | Comma-separated region IDs \(UUIDs\) to filter by |
|
||||
| `platformId` | string | No | Comma-separated platform IDs \(UUIDs\) to filter by |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of prompts |
|
||||
| `nextCursor` | string | Cursor for next page of results |
|
||||
| `prompts` | json | List of prompts |
|
||||
| ↳ `id` | string | Prompt ID |
|
||||
| ↳ `prompt` | string | Prompt text |
|
||||
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
|
||||
| ↳ `topicId` | string | Topic ID |
|
||||
| ↳ `topicName` | string | Topic name |
|
||||
| ↳ `tags` | json | Associated tags |
|
||||
| ↳ `regions` | json | Associated regions |
|
||||
| ↳ `platforms` | json | Associated platforms |
|
||||
| ↳ `createdAt` | string | When the prompt was created |
|
||||
|
||||
### `profound_category_assets`
|
||||
|
||||
List assets (companies/brands) for a specific category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `assets` | json | List of assets in the category |
|
||||
| ↳ `id` | string | Asset ID |
|
||||
| ↳ `name` | string | Asset/company name |
|
||||
| ↳ `website` | string | Website URL |
|
||||
| ↳ `alternateDomains` | json | Alternate domain names |
|
||||
| ↳ `isOwned` | boolean | Whether the asset is owned by the organization |
|
||||
| ↳ `createdAt` | string | When the asset was created |
|
||||
| ↳ `logoUrl` | string | URL of the asset logo |
|
||||
|
||||
### `profound_category_personas`
|
||||
|
||||
List personas for a specific category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `personas` | json | List of personas in the category |
|
||||
| ↳ `id` | string | Persona ID |
|
||||
| ↳ `name` | string | Persona name |
|
||||
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
|
||||
|
||||
### `profound_visibility_report`
|
||||
|
||||
Query AI visibility report for a category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `metrics` | string | Yes | Comma-separated metrics: share_of_voice, mentions_count, visibility_score, executions, average_position |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: date, region, topic, model, asset_name, prompt, tag, persona |
|
||||
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of rows in the report |
|
||||
| `data` | json | Report data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_sentiment_report`
|
||||
|
||||
Query sentiment report for a category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `metrics` | string | Yes | Comma-separated metrics: positive, negative, occurrences |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: theme, date, region, topic, model, asset_name, tag, prompt, sentiment_type, persona |
|
||||
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of rows in the report |
|
||||
| `data` | json | Report data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_citations_report`
|
||||
|
||||
Query citations report for a category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `metrics` | string | Yes | Comma-separated metrics: count, citation_share |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: hostname, path, date, region, topic, model, tag, prompt, url, root_domain, persona, citation_category |
|
||||
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"hostname","operator":"is","value":"example.com"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of rows in the report |
|
||||
| `data` | json | Report data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_query_fanouts`
|
||||
|
||||
Query fanout report showing how AI models expand prompts into sub-queries in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `metrics` | string | Yes | Comma-separated metrics: fanouts_per_execution, total_fanouts, share |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: prompt, query, model, region, date |
|
||||
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
|
||||
| `filters` | string | No | JSON array of filter objects |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of rows in the report |
|
||||
| `data` | json | Report data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_prompt_answers`
|
||||
|
||||
Get raw prompt answers data for a category in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `categoryId` | string | Yes | Category ID \(UUID\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"prompt_type","operator":"is","value":"visibility"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of answer rows |
|
||||
| `data` | json | Raw prompt answer data |
|
||||
| ↳ `prompt` | string | The prompt text |
|
||||
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
|
||||
| ↳ `response` | string | AI model response text |
|
||||
| ↳ `mentions` | json | Companies/assets mentioned in the response |
|
||||
| ↳ `citations` | json | URLs cited in the response |
|
||||
| ↳ `topic` | string | Topic name |
|
||||
| ↳ `region` | string | Region name |
|
||||
| ↳ `model` | string | AI model/platform name |
|
||||
| ↳ `asset` | string | Asset name |
|
||||
| ↳ `createdAt` | string | Timestamp when the answer was collected |
|
||||
|
||||
### `profound_bots_report`
|
||||
|
||||
Query bot traffic report with hourly granularity for a domain in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `domain` | string | Yes | Domain to query bot traffic for \(e.g. example.com\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
|
||||
| `metrics` | string | Yes | Comma-separated metrics: count, citations, indexing, training, last_visit |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, bot_name, bot_provider, bot_type |
|
||||
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of rows in the report |
|
||||
| `data` | json | Report data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_referrals_report`
|
||||
|
||||
Query human referral traffic report with hourly granularity for a domain in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `domain` | string | Yes | Domain to query referral traffic for \(e.g. example.com\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
|
||||
| `metrics` | string | Yes | Comma-separated metrics: visits, last_visit |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, referral_source, referral_type |
|
||||
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"referral_source","operator":"is","value":"openai"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of rows in the report |
|
||||
| `data` | json | Report data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_raw_logs`
|
||||
|
||||
Get raw traffic logs with filters for a domain in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `domain` | string | Yes | Domain to query logs for \(e.g. example.com\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"path","operator":"contains","value":"/blog"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of log entries |
|
||||
| `data` | json | Log data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values \(count\) |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_bot_logs`
|
||||
|
||||
Get identified bot visit logs with filters for a domain in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `domain` | string | Yes | Domain to query bot logs for \(e.g. example.com\) |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params, bot_name, bot_provider, bot_types |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of bot log entries |
|
||||
| `data` | json | Bot log data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values \(count\) |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_list_optimizations`
|
||||
|
||||
List content optimization entries for an asset in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `assetId` | string | Yes | Asset ID \(UUID\) |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
| `offset` | number | No | Offset for pagination \(default 0\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of optimization entries |
|
||||
| `optimizations` | json | List of content optimization entries |
|
||||
| ↳ `id` | string | Optimization ID \(UUID\) |
|
||||
| ↳ `title` | string | Content title |
|
||||
| ↳ `createdAt` | string | When the optimization was created |
|
||||
| ↳ `extractedInput` | string | Extracted input text |
|
||||
| ↳ `type` | string | Content type: file, text, or url |
|
||||
| ↳ `status` | string | Optimization status |
|
||||
|
||||
### `profound_optimization_analysis`
|
||||
|
||||
Get detailed content optimization analysis for a specific content item in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `assetId` | string | Yes | Asset ID \(UUID\) |
|
||||
| `contentId` | string | Yes | Content/optimization ID \(UUID\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | json | The analyzed content |
|
||||
| ↳ `format` | string | Content format: markdown or html |
|
||||
| ↳ `value` | string | Content text |
|
||||
| `aeoContentScore` | json | AEO content score with target zone |
|
||||
| ↳ `value` | number | AEO score value |
|
||||
| ↳ `targetZone` | json | Target zone range |
|
||||
| ↳ `low` | number | Low end of target range |
|
||||
| ↳ `high` | number | High end of target range |
|
||||
| `analysis` | json | Analysis breakdown by category |
|
||||
| ↳ `breakdown` | json | Array of scoring breakdowns |
|
||||
| ↳ `title` | string | Category title |
|
||||
| ↳ `weight` | number | Category weight |
|
||||
| ↳ `score` | number | Category score |
|
||||
| `recommendations` | json | Content optimization recommendations |
|
||||
| ↳ `title` | string | Recommendation title |
|
||||
| ↳ `status` | string | Status: done or pending |
|
||||
| ↳ `impact` | json | Impact details with section and score |
|
||||
| ↳ `suggestion` | json | Suggestion text and rationale |
|
||||
| ↳ `text` | string | Suggestion text |
|
||||
| ↳ `rationale` | string | Why this recommendation matters |
|
||||
|
||||
### `profound_prompt_volume`
|
||||
|
||||
Query prompt volume data to understand search demand across AI platforms in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
|
||||
| `metrics` | string | Yes | Comma-separated metrics: volume, change |
|
||||
| `dimensions` | string | No | Comma-separated dimensions: keyword, date, platform, country_code, matching_type, frequency |
|
||||
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
|
||||
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"keyword","operator":"contains","value":"best"\}\] |
|
||||
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalRows` | number | Total number of rows in the report |
|
||||
| `data` | json | Volume data rows with metrics and dimension values |
|
||||
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
|
||||
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
|
||||
|
||||
### `profound_citation_prompts`
|
||||
|
||||
Get prompts that cite a specific domain across AI platforms in Profound
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Profound API Key |
|
||||
| `inputDomain` | string | Yes | Domain to look up citations for \(e.g. ramp.com\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `data` | json | Citation prompt data for the queried domain |
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
510
apps/docs/content/docs/en/tools/rootly.mdx
Normal file
510
apps/docs/content/docs/en/tools/rootly.mdx
Normal file
@@ -0,0 +1,510 @@
|
||||
---
|
||||
title: Rootly
|
||||
description: Manage incidents, alerts, and on-call with Rootly
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="rootly"
|
||||
color="#6C72C8"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Rootly](https://rootly.com/) is an incident management platform that helps teams respond to, mitigate, and learn from incidents — all without leaving Slack or your existing tools. Rootly automates on-call alerting, incident workflows, status page updates, and retrospectives so engineering teams can resolve issues faster and reduce toil.
|
||||
|
||||
**Why Rootly?**
|
||||
- **End-to-End Incident Management:** Create, track, update, and resolve incidents with full lifecycle support — from initial triage through retrospective.
|
||||
- **On-Call Alerting:** Create and manage alerts with deduplication, routing, and escalation to ensure the right people are notified immediately.
|
||||
- **Timeline Events:** Add structured timeline events to incidents for clear, auditable incident narratives.
|
||||
- **Service Catalog:** Maintain a catalog of services and map them to incidents for precise impact tracking.
|
||||
- **Severity & Prioritization:** Use configurable severity levels to prioritize incidents and drive appropriate response urgency.
|
||||
- **Retrospectives:** Access post-incident retrospectives to identify root causes, capture learnings, and drive reliability improvements.
|
||||
|
||||
**Using Rootly in Sim**
|
||||
|
||||
Sim's Rootly integration connects your agentic workflows directly to your Rootly account using an API key. With operations spanning incidents, alerts, services, severities, teams, environments, functionalities, incident types, and retrospectives, you can build powerful incident management automations without writing backend code.
|
||||
|
||||
**Key benefits of using Rootly in Sim:**
|
||||
- **Automated incident creation:** Trigger incident creation from monitoring alerts, customer reports, or anomaly detection workflows with full metadata including severity, services, and teams.
|
||||
- **Incident lifecycle automation:** Automatically update incident status, add timeline events, and attach mitigation or resolution messages as your response progresses.
|
||||
- **Alert management:** Create and list alerts with deduplication support to integrate Rootly into your existing monitoring and notification pipelines.
|
||||
- **Organizational awareness:** Query services, severities, teams, environments, functionalities, and incident types to build context-aware incident workflows.
|
||||
- **Retrospective insights:** List and filter retrospectives to feed post-incident learnings into continuous improvement workflows.
|
||||
|
||||
Whether you're automating incident response, building on-call alerting pipelines, or driving post-incident learning, Rootly in Sim gives you direct, secure access to the Rootly API — no middleware required. Simply configure your API key, select the operation you need, and let Sim handle the rest.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Rootly incident management into workflows. Create and manage incidents, alerts, services, severities, and retrospectives.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `rootly_create_incident`
|
||||
|
||||
Create a new incident in Rootly with optional severity, services, and teams.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `title` | string | No | The title of the incident \(auto-generated if not provided\) |
|
||||
| `summary` | string | No | A summary of the incident |
|
||||
| `severityId` | string | No | Severity ID to attach to the incident |
|
||||
| `status` | string | No | Incident status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
|
||||
| `kind` | string | No | Incident kind \(normal, normal_sub, test, test_sub, example, example_sub, backfilled, scheduled, scheduled_sub\) |
|
||||
| `serviceIds` | string | No | Comma-separated service IDs to attach |
|
||||
| `environmentIds` | string | No | Comma-separated environment IDs to attach |
|
||||
| `groupIds` | string | No | Comma-separated team/group IDs to attach |
|
||||
| `incidentTypeIds` | string | No | Comma-separated incident type IDs to attach |
|
||||
| `functionalityIds` | string | No | Comma-separated functionality IDs to attach |
|
||||
| `labels` | string | No | Labels as JSON object, e.g. \{"platform":"osx","version":"1.29"\} |
|
||||
| `private` | boolean | No | Create as a private incident \(cannot be undone\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `incident` | object | The created incident |
|
||||
| ↳ `id` | string | Unique incident ID |
|
||||
| ↳ `sequentialId` | number | Sequential incident number |
|
||||
| ↳ `title` | string | Incident title |
|
||||
| ↳ `slug` | string | Incident slug |
|
||||
| ↳ `kind` | string | Incident kind |
|
||||
| ↳ `summary` | string | Incident summary |
|
||||
| ↳ `status` | string | Incident status |
|
||||
| ↳ `private` | boolean | Whether the incident is private |
|
||||
| ↳ `url` | string | URL to the incident |
|
||||
| ↳ `shortUrl` | string | Short URL to the incident |
|
||||
| ↳ `severityName` | string | Severity name |
|
||||
| ↳ `severityId` | string | Severity ID |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| ↳ `startedAt` | string | Start date |
|
||||
| ↳ `mitigatedAt` | string | Mitigation date |
|
||||
| ↳ `resolvedAt` | string | Resolution date |
|
||||
| ↳ `closedAt` | string | Closed date |
|
||||
|
||||
### `rootly_get_incident`
|
||||
|
||||
Retrieve a single incident by ID from Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `incidentId` | string | Yes | The ID of the incident to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `incident` | object | The incident details |
|
||||
| ↳ `id` | string | Unique incident ID |
|
||||
| ↳ `sequentialId` | number | Sequential incident number |
|
||||
| ↳ `title` | string | Incident title |
|
||||
| ↳ `slug` | string | Incident slug |
|
||||
| ↳ `kind` | string | Incident kind |
|
||||
| ↳ `summary` | string | Incident summary |
|
||||
| ↳ `status` | string | Incident status |
|
||||
| ↳ `private` | boolean | Whether the incident is private |
|
||||
| ↳ `url` | string | URL to the incident |
|
||||
| ↳ `shortUrl` | string | Short URL to the incident |
|
||||
| ↳ `severityName` | string | Severity name |
|
||||
| ↳ `severityId` | string | Severity ID |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| ↳ `startedAt` | string | Start date |
|
||||
| ↳ `mitigatedAt` | string | Mitigation date |
|
||||
| ↳ `resolvedAt` | string | Resolution date |
|
||||
| ↳ `closedAt` | string | Closed date |
|
||||
|
||||
### `rootly_update_incident`
|
||||
|
||||
Update an existing incident in Rootly (status, severity, summary, etc.).
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `incidentId` | string | Yes | The ID of the incident to update |
|
||||
| `title` | string | No | Updated incident title |
|
||||
| `summary` | string | No | Updated incident summary |
|
||||
| `severityId` | string | No | Updated severity ID |
|
||||
| `status` | string | No | Updated status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
|
||||
| `kind` | string | No | Incident kind \(normal, normal_sub, test, test_sub, example, example_sub, backfilled, scheduled, scheduled_sub\) |
|
||||
| `private` | boolean | No | Set incident as private \(cannot be undone\) |
|
||||
| `serviceIds` | string | No | Comma-separated service IDs |
|
||||
| `environmentIds` | string | No | Comma-separated environment IDs |
|
||||
| `groupIds` | string | No | Comma-separated team/group IDs |
|
||||
| `incidentTypeIds` | string | No | Comma-separated incident type IDs to attach |
|
||||
| `functionalityIds` | string | No | Comma-separated functionality IDs to attach |
|
||||
| `labels` | string | No | Labels as JSON object, e.g. \{"platform":"osx","version":"1.29"\} |
|
||||
| `mitigationMessage` | string | No | How was the incident mitigated? |
|
||||
| `resolutionMessage` | string | No | How was the incident resolved? |
|
||||
| `cancellationMessage` | string | No | Why was the incident cancelled? |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `incident` | object | The updated incident |
|
||||
| ↳ `id` | string | Unique incident ID |
|
||||
| ↳ `sequentialId` | number | Sequential incident number |
|
||||
| ↳ `title` | string | Incident title |
|
||||
| ↳ `slug` | string | Incident slug |
|
||||
| ↳ `kind` | string | Incident kind |
|
||||
| ↳ `summary` | string | Incident summary |
|
||||
| ↳ `status` | string | Incident status |
|
||||
| ↳ `private` | boolean | Whether the incident is private |
|
||||
| ↳ `url` | string | URL to the incident |
|
||||
| ↳ `shortUrl` | string | Short URL to the incident |
|
||||
| ↳ `severityName` | string | Severity name |
|
||||
| ↳ `severityId` | string | Severity ID |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| ↳ `startedAt` | string | Start date |
|
||||
| ↳ `mitigatedAt` | string | Mitigation date |
|
||||
| ↳ `resolvedAt` | string | Resolution date |
|
||||
| ↳ `closedAt` | string | Closed date |
|
||||
|
||||
### `rootly_list_incidents`
|
||||
|
||||
List incidents from Rootly with optional filtering by status, severity, and more.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `status` | string | No | Filter by status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
|
||||
| `severity` | string | No | Filter by severity slug |
|
||||
| `search` | string | No | Search term to filter incidents |
|
||||
| `services` | string | No | Filter by service slugs \(comma-separated\) |
|
||||
| `teams` | string | No | Filter by team slugs \(comma-separated\) |
|
||||
| `environments` | string | No | Filter by environment slugs \(comma-separated\) |
|
||||
| `sort` | string | No | Sort order \(e.g., -created_at, created_at, -started_at\) |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `incidents` | array | List of incidents |
|
||||
| ↳ `id` | string | Unique incident ID |
|
||||
| ↳ `sequentialId` | number | Sequential incident number |
|
||||
| ↳ `title` | string | Incident title |
|
||||
| ↳ `slug` | string | Incident slug |
|
||||
| ↳ `kind` | string | Incident kind |
|
||||
| ↳ `summary` | string | Incident summary |
|
||||
| ↳ `status` | string | Incident status |
|
||||
| ↳ `private` | boolean | Whether the incident is private |
|
||||
| ↳ `url` | string | URL to the incident |
|
||||
| ↳ `shortUrl` | string | Short URL to the incident |
|
||||
| ↳ `severityName` | string | Severity name |
|
||||
| ↳ `severityId` | string | Severity ID |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| ↳ `startedAt` | string | Start date |
|
||||
| ↳ `mitigatedAt` | string | Mitigation date |
|
||||
| ↳ `resolvedAt` | string | Resolution date |
|
||||
| ↳ `closedAt` | string | Closed date |
|
||||
| `totalCount` | number | Total number of incidents returned |
|
||||
|
||||
### `rootly_create_alert`
|
||||
|
||||
Create a new alert in Rootly for on-call notification and routing.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `summary` | string | Yes | The summary of the alert |
|
||||
| `description` | string | No | A detailed description of the alert |
|
||||
| `source` | string | Yes | The source of the alert \(e.g., api, manual, datadog, pagerduty\) |
|
||||
| `status` | string | No | Alert status on creation \(open, triggered\) |
|
||||
| `serviceIds` | string | No | Comma-separated service IDs to attach |
|
||||
| `groupIds` | string | No | Comma-separated team/group IDs to attach |
|
||||
| `environmentIds` | string | No | Comma-separated environment IDs to attach |
|
||||
| `externalId` | string | No | External ID for the alert |
|
||||
| `externalUrl` | string | No | External URL for the alert |
|
||||
| `deduplicationKey` | string | No | Alerts sharing the same deduplication key are treated as a single alert |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `alert` | object | The created alert |
|
||||
| ↳ `id` | string | Unique alert ID |
|
||||
| ↳ `summary` | string | Alert summary |
|
||||
| ↳ `description` | string | Alert description |
|
||||
| ↳ `source` | string | Alert source |
|
||||
| ↳ `status` | string | Alert status |
|
||||
| ↳ `externalId` | string | External ID |
|
||||
| ↳ `externalUrl` | string | External URL |
|
||||
| ↳ `deduplicationKey` | string | Deduplication key |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
|
||||
### `rootly_list_alerts`
|
||||
|
||||
List alerts from Rootly with optional filtering by status, source, and services.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `status` | string | No | Filter by status \(open, triggered, acknowledged, resolved\) |
|
||||
| `source` | string | No | Filter by source \(e.g., api, datadog, pagerduty\) |
|
||||
| `services` | string | No | Filter by service slugs \(comma-separated\) |
|
||||
| `environments` | string | No | Filter by environment slugs \(comma-separated\) |
|
||||
| `groups` | string | No | Filter by team/group slugs \(comma-separated\) |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `alerts` | array | List of alerts |
|
||||
| ↳ `id` | string | Unique alert ID |
|
||||
| ↳ `summary` | string | Alert summary |
|
||||
| ↳ `description` | string | Alert description |
|
||||
| ↳ `source` | string | Alert source |
|
||||
| ↳ `status` | string | Alert status |
|
||||
| ↳ `externalId` | string | External ID |
|
||||
| ↳ `externalUrl` | string | External URL |
|
||||
| ↳ `deduplicationKey` | string | Deduplication key |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of alerts returned |
|
||||
|
||||
### `rootly_add_incident_event`
|
||||
|
||||
Add a timeline event to an existing incident in Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `incidentId` | string | Yes | The ID of the incident to add the event to |
|
||||
| `event` | string | Yes | The summary/description of the event |
|
||||
| `visibility` | string | No | Event visibility \(internal or external\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `eventId` | string | The ID of the created event |
|
||||
| `event` | string | The event summary |
|
||||
| `visibility` | string | Event visibility \(internal or external\) |
|
||||
| `occurredAt` | string | When the event occurred |
|
||||
| `createdAt` | string | Creation date |
|
||||
| `updatedAt` | string | Last update date |
|
||||
|
||||
### `rootly_list_services`
|
||||
|
||||
List services from Rootly with optional search filtering.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `search` | string | No | Search term to filter services |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `services` | array | List of services |
|
||||
| ↳ `id` | string | Unique service ID |
|
||||
| ↳ `name` | string | Service name |
|
||||
| ↳ `slug` | string | Service slug |
|
||||
| ↳ `description` | string | Service description |
|
||||
| ↳ `color` | string | Service color |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of services returned |
|
||||
|
||||
### `rootly_list_severities`
|
||||
|
||||
List severity levels configured in Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `search` | string | No | Search term to filter severities |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `severities` | array | List of severity levels |
|
||||
| ↳ `id` | string | Unique severity ID |
|
||||
| ↳ `name` | string | Severity name |
|
||||
| ↳ `slug` | string | Severity slug |
|
||||
| ↳ `description` | string | Severity description |
|
||||
| ↳ `severity` | string | Severity level \(critical, high, medium, low\) |
|
||||
| ↳ `color` | string | Severity color |
|
||||
| ↳ `position` | number | Display position |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of severities returned |
|
||||
|
||||
### `rootly_list_teams`
|
||||
|
||||
List teams (groups) configured in Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `search` | string | No | Search term to filter teams |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `teams` | array | List of teams |
|
||||
| ↳ `id` | string | Unique team ID |
|
||||
| ↳ `name` | string | Team name |
|
||||
| ↳ `slug` | string | Team slug |
|
||||
| ↳ `description` | string | Team description |
|
||||
| ↳ `color` | string | Team color |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of teams returned |
|
||||
|
||||
### `rootly_list_environments`
|
||||
|
||||
List environments configured in Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `search` | string | No | Search term to filter environments |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `environments` | array | List of environments |
|
||||
| ↳ `id` | string | Unique environment ID |
|
||||
| ↳ `name` | string | Environment name |
|
||||
| ↳ `slug` | string | Environment slug |
|
||||
| ↳ `description` | string | Environment description |
|
||||
| ↳ `color` | string | Environment color |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of environments returned |
|
||||
|
||||
### `rootly_list_incident_types`
|
||||
|
||||
List incident types configured in Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `search` | string | No | Filter incident types by name |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `incidentTypes` | array | List of incident types |
|
||||
| ↳ `id` | string | Unique incident type ID |
|
||||
| ↳ `name` | string | Incident type name |
|
||||
| ↳ `slug` | string | Incident type slug |
|
||||
| ↳ `description` | string | Incident type description |
|
||||
| ↳ `color` | string | Incident type color |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of incident types returned |
|
||||
|
||||
### `rootly_list_functionalities`
|
||||
|
||||
List functionalities configured in Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `search` | string | No | Search term to filter functionalities |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `functionalities` | array | List of functionalities |
|
||||
| ↳ `id` | string | Unique functionality ID |
|
||||
| ↳ `name` | string | Functionality name |
|
||||
| ↳ `slug` | string | Functionality slug |
|
||||
| ↳ `description` | string | Functionality description |
|
||||
| ↳ `color` | string | Functionality color |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of functionalities returned |
|
||||
|
||||
### `rootly_list_retrospectives`
|
||||
|
||||
List incident retrospectives (post-mortems) from Rootly.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Rootly API key |
|
||||
| `status` | string | No | Filter by status \(draft, published\) |
|
||||
| `search` | string | No | Search term to filter retrospectives |
|
||||
| `pageSize` | number | No | Number of items per page \(default: 20\) |
|
||||
| `pageNumber` | number | No | Page number for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `retrospectives` | array | List of retrospectives |
|
||||
| ↳ `id` | string | Unique retrospective ID |
|
||||
| ↳ `title` | string | Retrospective title |
|
||||
| ↳ `status` | string | Status \(draft or published\) |
|
||||
| ↳ `url` | string | URL to the retrospective |
|
||||
| ↳ `startedAt` | string | Incident start date |
|
||||
| ↳ `mitigatedAt` | string | Mitigation date |
|
||||
| ↳ `resolvedAt` | string | Resolution date |
|
||||
| ↳ `createdAt` | string | Creation date |
|
||||
| ↳ `updatedAt` | string | Last update date |
|
||||
| `totalCount` | number | Total number of retrospectives returned |
|
||||
|
||||
|
||||
157
apps/docs/content/docs/en/tools/secrets_manager.mdx
Normal file
157
apps/docs/content/docs/en/tools/secrets_manager.mdx
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: AWS Secrets Manager
|
||||
description: Connect to AWS Secrets Manager
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="secrets_manager"
|
||||
color="linear-gradient(45deg, #BD0816 0%, #FF5252 100%)"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) is a secrets management service that helps you protect access to your applications, services, and IT resources. It enables you to rotate, manage, and retrieve database credentials, API keys, and other secrets throughout their lifecycle.
|
||||
|
||||
With AWS Secrets Manager, you can:
|
||||
|
||||
- **Securely store secrets**: Encrypt secrets at rest using AWS KMS encryption keys
|
||||
- **Retrieve secrets programmatically**: Access secrets from your applications and workflows without hardcoding credentials
|
||||
- **Rotate secrets automatically**: Configure automatic rotation for supported services like RDS, Redshift, and DocumentDB
|
||||
- **Audit access**: Track secret access and changes through AWS CloudTrail integration
|
||||
- **Control access with IAM**: Use fine-grained IAM policies to manage who can access which secrets
|
||||
- **Replicate across regions**: Automatically replicate secrets to multiple AWS regions for disaster recovery
|
||||
|
||||
In Sim, the AWS Secrets Manager integration allows your workflows to securely retrieve credentials and configuration values at runtime, create and manage secrets as part of automation pipelines, and maintain a centralized secrets store that your agents can access. This is particularly useful for workflows that need to authenticate with external services, rotate credentials, or manage sensitive configuration across environments — all without exposing secrets in your workflow definitions.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `secrets_manager_get_secret`
|
||||
|
||||
Retrieve a secret value from AWS Secrets Manager
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `secretId` | string | Yes | The name or ARN of the secret to retrieve |
|
||||
| `versionId` | string | No | The unique identifier of the version to retrieve |
|
||||
| `versionStage` | string | No | The staging label of the version to retrieve \(e.g., AWSCURRENT, AWSPREVIOUS\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `name` | string | Name of the secret |
|
||||
| `secretValue` | string | The decrypted secret value |
|
||||
| `arn` | string | ARN of the secret |
|
||||
| `versionId` | string | Version ID of the secret |
|
||||
| `versionStages` | array | Staging labels attached to this version |
|
||||
| `createdDate` | string | Date the secret was created |
|
||||
|
||||
### `secrets_manager_list_secrets`
|
||||
|
||||
List secrets stored in AWS Secrets Manager
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `maxResults` | number | No | Maximum number of secrets to return \(1-100, default 100\) |
|
||||
| `nextToken` | string | No | Pagination token from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `secrets` | json | List of secrets with name, ARN, description, and dates |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of secrets returned |
|
||||
|
||||
### `secrets_manager_create_secret`
|
||||
|
||||
Create a new secret in AWS Secrets Manager
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `name` | string | Yes | Name of the secret to create |
|
||||
| `secretValue` | string | Yes | The secret value \(plain text or JSON string\) |
|
||||
| `description` | string | No | Description of the secret |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `name` | string | Name of the created secret |
|
||||
| `arn` | string | ARN of the created secret |
|
||||
| `versionId` | string | Version ID of the created secret |
|
||||
|
||||
### `secrets_manager_update_secret`
|
||||
|
||||
Update the value of an existing secret in AWS Secrets Manager
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `secretId` | string | Yes | The name or ARN of the secret to update |
|
||||
| `secretValue` | string | Yes | The new secret value \(plain text or JSON string\) |
|
||||
| `description` | string | No | Updated description of the secret |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `name` | string | Name of the updated secret |
|
||||
| `arn` | string | ARN of the updated secret |
|
||||
| `versionId` | string | Version ID of the updated secret |
|
||||
|
||||
### `secrets_manager_delete_secret`
|
||||
|
||||
Delete a secret from AWS Secrets Manager
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `secretId` | string | Yes | The name or ARN of the secret to delete |
|
||||
| `recoveryWindowInDays` | number | No | Number of days before permanent deletion \(7-30, default 30\) |
|
||||
| `forceDelete` | boolean | No | If true, immediately delete without recovery window |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `name` | string | Name of the deleted secret |
|
||||
| `arn` | string | ARN of the deleted secret |
|
||||
| `deletionDate` | string | Scheduled deletion date |
|
||||
|
||||
|
||||
498
apps/docs/content/docs/en/tools/tailscale.mdx
Normal file
498
apps/docs/content/docs/en/tools/tailscale.mdx
Normal file
@@ -0,0 +1,498 @@
|
||||
---
|
||||
title: Tailscale
|
||||
description: Manage devices and network settings in your Tailscale tailnet
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="tailscale"
|
||||
color="#2E2D2D"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
## Overview
|
||||
|
||||
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on WireGuard that makes it easy to connect devices, services, and users across any network. The Tailscale block lets you automate network management tasks like device provisioning, access control, route management, and DNS configuration directly from your Sim workflows.
|
||||
|
||||
## Authentication
|
||||
|
||||
The Tailscale block uses API key authentication. To get an API key:
|
||||
|
||||
1. Go to the [Tailscale admin console](https://login.tailscale.com/admin/settings/keys)
|
||||
2. Navigate to **Settings > Keys**
|
||||
3. Click **Generate API key**
|
||||
4. Set an expiry (1-90 days) and copy the key (starts with `tskey-api-`)
|
||||
|
||||
You must have an **Owner**, **Admin**, **IT admin**, or **Network admin** role to generate API keys.
|
||||
|
||||
## Tailnet Identifier
|
||||
|
||||
Every operation requires a **tailnet** parameter. This is typically your organization's domain name (e.g., `example.com`). You can also use `"-"` to refer to your default tailnet.
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- **Device inventory**: List and monitor all devices connected to your network
|
||||
- **Automated provisioning**: Create and manage auth keys to pre-authorize new devices
|
||||
- **Access control**: Authorize or deauthorize devices, manage device tags for ACL policies
|
||||
- **Route management**: View and enable subnet routes for devices acting as subnet routers
|
||||
- **DNS management**: Configure nameservers, MagicDNS, and search paths
|
||||
- **Key lifecycle**: Create, list, inspect, and revoke auth keys
|
||||
- **User auditing**: List all users in the tailnet and their roles
|
||||
- **Policy review**: Retrieve the current ACL policy for inspection or backup
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Interact with the Tailscale API to manage devices, DNS, ACLs, auth keys, users, and routes across your tailnet.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `tailscale_list_devices`
|
||||
|
||||
List all devices in the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `devices` | array | List of devices in the tailnet |
|
||||
| ↳ `id` | string | Device ID |
|
||||
| ↳ `name` | string | Device name |
|
||||
| ↳ `hostname` | string | Device hostname |
|
||||
| ↳ `user` | string | Associated user |
|
||||
| ↳ `os` | string | Operating system |
|
||||
| ↳ `clientVersion` | string | Tailscale client version |
|
||||
| ↳ `addresses` | array | Tailscale IP addresses |
|
||||
| ↳ `tags` | array | Device tags |
|
||||
| ↳ `authorized` | boolean | Whether the device is authorized |
|
||||
| ↳ `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
|
||||
| ↳ `lastSeen` | string | Last seen timestamp |
|
||||
| ↳ `created` | string | Creation timestamp |
|
||||
| `count` | number | Total number of devices |
|
||||
|
||||
### `tailscale_get_device`
|
||||
|
||||
Get details of a specific device by ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `deviceId` | string | Yes | Device ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Device ID |
|
||||
| `name` | string | Device name |
|
||||
| `hostname` | string | Device hostname |
|
||||
| `user` | string | Associated user |
|
||||
| `os` | string | Operating system |
|
||||
| `clientVersion` | string | Tailscale client version |
|
||||
| `addresses` | array | Tailscale IP addresses |
|
||||
| `tags` | array | Device tags |
|
||||
| `authorized` | boolean | Whether the device is authorized |
|
||||
| `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
|
||||
| `lastSeen` | string | Last seen timestamp |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `isExternal` | boolean | Whether the device is external |
|
||||
| `updateAvailable` | boolean | Whether an update is available |
|
||||
| `machineKey` | string | Machine key |
|
||||
| `nodeKey` | string | Node key |
|
||||
|
||||
### `tailscale_delete_device`
|
||||
|
||||
Remove a device from the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `deviceId` | string | Yes | Device ID to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the device was successfully deleted |
|
||||
| `deviceId` | string | ID of the deleted device |
|
||||
|
||||
### `tailscale_authorize_device`
|
||||
|
||||
Authorize or deauthorize a device on the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `deviceId` | string | Yes | Device ID to authorize |
|
||||
| `authorized` | boolean | Yes | Whether to authorize \(true\) or deauthorize \(false\) the device |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the operation succeeded |
|
||||
| `deviceId` | string | Device ID |
|
||||
| `authorized` | boolean | Authorization status after the operation |
|
||||
|
||||
### `tailscale_set_device_tags`
|
||||
|
||||
Set tags on a device in the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `deviceId` | string | Yes | Device ID |
|
||||
| `tags` | string | Yes | Comma-separated list of tags \(e.g., "tag:server,tag:production"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the tags were successfully set |
|
||||
| `deviceId` | string | Device ID |
|
||||
| `tags` | array | Tags set on the device |
|
||||
|
||||
### `tailscale_get_device_routes`
|
||||
|
||||
Get the subnet routes for a device
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `deviceId` | string | Yes | Device ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `advertisedRoutes` | array | Subnet routes the device is advertising |
|
||||
| `enabledRoutes` | array | Subnet routes that are approved/enabled |
|
||||
|
||||
### `tailscale_set_device_routes`
|
||||
|
||||
Set the enabled subnet routes for a device
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `deviceId` | string | Yes | Device ID |
|
||||
| `routes` | string | Yes | Comma-separated list of subnet routes to enable \(e.g., "10.0.0.0/24,192.168.1.0/24"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `advertisedRoutes` | array | Subnet routes the device is advertising |
|
||||
| `enabledRoutes` | array | Subnet routes that are now enabled |
|
||||
|
||||
### `tailscale_update_device_key`
|
||||
|
||||
Enable or disable key expiry on a device
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `deviceId` | string | Yes | Device ID |
|
||||
| `keyExpiryDisabled` | boolean | Yes | Whether to disable key expiry \(true\) or enable it \(false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the operation succeeded |
|
||||
| `deviceId` | string | Device ID |
|
||||
| `keyExpiryDisabled` | boolean | Whether key expiry is now disabled |
|
||||
|
||||
### `tailscale_list_dns_nameservers`
|
||||
|
||||
Get the DNS nameservers configured for the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `dns` | array | List of DNS nameserver addresses |
|
||||
| `magicDNS` | boolean | Whether MagicDNS is enabled |
|
||||
|
||||
### `tailscale_set_dns_nameservers`
|
||||
|
||||
Set the DNS nameservers for the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `dns` | string | Yes | Comma-separated list of DNS nameserver IP addresses \(e.g., "8.8.8.8,8.8.4.4"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `dns` | array | Updated list of DNS nameserver addresses |
|
||||
| `magicDNS` | boolean | Whether MagicDNS is enabled |
|
||||
|
||||
### `tailscale_get_dns_preferences`
|
||||
|
||||
Get the DNS preferences for the tailnet including MagicDNS status
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `magicDNS` | boolean | Whether MagicDNS is enabled |
|
||||
|
||||
### `tailscale_set_dns_preferences`
|
||||
|
||||
Set DNS preferences for the tailnet (enable/disable MagicDNS)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `magicDNS` | boolean | Yes | Whether to enable \(true\) or disable \(false\) MagicDNS |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `magicDNS` | boolean | Updated MagicDNS status |
|
||||
|
||||
### `tailscale_get_dns_searchpaths`
|
||||
|
||||
Get the DNS search paths configured for the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `searchPaths` | array | List of DNS search path domains |
|
||||
|
||||
### `tailscale_set_dns_searchpaths`
|
||||
|
||||
Set the DNS search paths for the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `searchPaths` | string | Yes | Comma-separated list of DNS search path domains \(e.g., "corp.example.com,internal.example.com"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `searchPaths` | array | Updated list of DNS search path domains |
|
||||
|
||||
### `tailscale_list_users`
|
||||
|
||||
List all users in the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `users` | array | List of users in the tailnet |
|
||||
| ↳ `id` | string | User ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `loginName` | string | Login name / email |
|
||||
| ↳ `profilePicURL` | string | Profile picture URL |
|
||||
| ↳ `role` | string | User role \(owner, admin, member, etc.\) |
|
||||
| ↳ `status` | string | User status \(active, suspended, etc.\) |
|
||||
| ↳ `type` | string | User type \(member, shared, tagged\) |
|
||||
| ↳ `created` | string | Creation timestamp |
|
||||
| ↳ `lastSeen` | string | Last seen timestamp |
|
||||
| ↳ `deviceCount` | number | Number of devices owned by user |
|
||||
| `count` | number | Total number of users |
|
||||
|
||||
### `tailscale_create_auth_key`
|
||||
|
||||
Create a new auth key for the tailnet to pre-authorize devices
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `reusable` | boolean | No | Whether the key can be used more than once |
|
||||
| `ephemeral` | boolean | No | Whether devices authenticated with this key are ephemeral |
|
||||
| `preauthorized` | boolean | No | Whether devices are pre-authorized \(skip manual approval\) |
|
||||
| `tags` | string | No | Comma-separated list of tags for devices using this key \(e.g., "tag:server,tag:prod"\) |
|
||||
| `description` | string | No | Description for the auth key |
|
||||
| `expirySeconds` | number | No | Key expiry time in seconds \(default: 90 days\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Auth key ID |
|
||||
| `key` | string | The auth key value \(only shown once at creation\) |
|
||||
| `description` | string | Key description |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `expires` | string | Expiration timestamp |
|
||||
| `revoked` | string | Revocation timestamp \(empty if not revoked\) |
|
||||
| `capabilities` | object | Key capabilities |
|
||||
| ↳ `reusable` | boolean | Whether the key is reusable |
|
||||
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
|
||||
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
|
||||
| ↳ `tags` | array | Tags applied to devices using this key |
|
||||
|
||||
### `tailscale_list_auth_keys`
|
||||
|
||||
List all auth keys in the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `keys` | array | List of auth keys |
|
||||
| ↳ `id` | string | Auth key ID |
|
||||
| ↳ `description` | string | Key description |
|
||||
| ↳ `created` | string | Creation timestamp |
|
||||
| ↳ `expires` | string | Expiration timestamp |
|
||||
| ↳ `revoked` | string | Revocation timestamp |
|
||||
| ↳ `capabilities` | object | Key capabilities |
|
||||
| ↳ `reusable` | boolean | Whether the key is reusable |
|
||||
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
|
||||
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
|
||||
| ↳ `tags` | array | Tags applied to devices |
|
||||
| `count` | number | Total number of auth keys |
|
||||
|
||||
### `tailscale_get_auth_key`
|
||||
|
||||
Get details of a specific auth key
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `keyId` | string | Yes | Auth key ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Auth key ID |
|
||||
| `description` | string | Key description |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `expires` | string | Expiration timestamp |
|
||||
| `revoked` | string | Revocation timestamp |
|
||||
| `capabilities` | object | Key capabilities |
|
||||
| ↳ `reusable` | boolean | Whether the key is reusable |
|
||||
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
|
||||
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
|
||||
| ↳ `tags` | array | Tags applied to devices using this key |
|
||||
|
||||
### `tailscale_delete_auth_key`
|
||||
|
||||
Revoke and delete an auth key
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
| `keyId` | string | Yes | Auth key ID to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the auth key was successfully deleted |
|
||||
| `keyId` | string | ID of the deleted auth key |
|
||||
|
||||
### `tailscale_get_acl`
|
||||
|
||||
Get the current ACL policy for the tailnet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Tailscale API key |
|
||||
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `acl` | string | ACL policy as JSON string |
|
||||
| `etag` | string | ETag for the current ACL version \(use with If-Match header for updates\) |
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ Detecta información de identificación personal utilizando Microsoft Presidio.
|
||||
**Casos de uso:**
|
||||
- Bloquear contenido que contiene información personal sensible
|
||||
- Enmascarar PII antes de registrar o almacenar datos
|
||||
- Cumplimiento de GDPR, HIPAA y otras regulaciones de privacidad
|
||||
- Cumplimiento de GDPR y otras regulaciones de privacidad
|
||||
- Sanear entradas de usuario antes del procesamiento
|
||||
|
||||
## Configuración
|
||||
|
||||
@@ -131,7 +131,7 @@ Détecte les informations personnelles identifiables à l'aide de Microsoft Pres
|
||||
**Cas d'utilisation :**
|
||||
- Bloquer le contenu contenant des informations personnelles sensibles
|
||||
- Masquer les PII avant de journaliser ou stocker des données
|
||||
- Conformité avec le RGPD, HIPAA et autres réglementations sur la confidentialité
|
||||
- Conformité avec le RGPD et autres réglementations sur la confidentialité
|
||||
- Assainir les entrées utilisateur avant traitement
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -131,7 +131,7 @@ Microsoft Presidioを使用して個人を特定できる情報を検出しま
|
||||
**ユースケース:**
|
||||
- 機密性の高い個人情報を含むコンテンツをブロック
|
||||
- データのログ記録や保存前にPIIをマスク
|
||||
- GDPR、HIPAA、その他のプライバシー規制への準拠
|
||||
- GDPR、その他のプライバシー規制への準拠
|
||||
- 処理前のユーザー入力のサニタイズ
|
||||
|
||||
## 設定
|
||||
|
||||
@@ -131,7 +131,7 @@ Guardrails 模块通过针对多种验证类型检查内容,验证并保护您
|
||||
**使用场景:**
|
||||
- 阻止包含敏感个人信息的内容
|
||||
- 在记录或存储数据之前屏蔽 PII
|
||||
- 符合 GDPR、HIPAA 和其他隐私法规
|
||||
- 符合 GDPR 和其他隐私法规
|
||||
- 在处理之前清理用户输入
|
||||
|
||||
## 配置
|
||||
|
||||
BIN
apps/docs/public/static/credentials/add-service-account.png
Normal file
BIN
apps/docs/public/static/credentials/add-service-account.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/docs/public/static/credentials/gcp-add-client-id.png
Normal file
BIN
apps/docs/public/static/credentials/gcp-add-client-id.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/docs/public/static/credentials/gcp-create-private-key.png
Normal file
BIN
apps/docs/public/static/credentials/gcp-create-private-key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -28,6 +28,15 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
|
||||
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
|
||||
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
|
||||
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
|
||||
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
|
||||
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
|
||||
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
|
||||
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key
|
||||
# AZURE_OPENAI_API_VERSION= # Azure OpenAI API version
|
||||
# AZURE_ANTHROPIC_ENDPOINT= # Azure Anthropic endpoint (AI Foundry)
|
||||
# AZURE_ANTHROPIC_API_KEY= # Azure Anthropic API key
|
||||
# AZURE_ANTHROPIC_API_VERSION= # Azure Anthropic API version (e.g., 2023-06-01)
|
||||
# NEXT_PUBLIC_AZURE_CONFIGURED=true # Set when Azure credentials are pre-configured above. Hides endpoint/key/version fields in Agent block UI.
|
||||
|
||||
# Admin API (Optional - for self-hosted GitOps)
|
||||
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/** Shared className for primary auth form submit buttons across all auth pages. */
|
||||
export const AUTH_SUBMIT_BTN =
|
||||
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const
|
||||
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
|
||||
export const AUTH_PRIMARY_CTA_BASE =
|
||||
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
|
||||
|
||||
/** Full-width variant used for primary auth form submit buttons. */
|
||||
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const
|
||||
|
||||
@@ -288,7 +288,6 @@ export default function Collaboration() {
|
||||
width={876}
|
||||
height={480}
|
||||
className='h-full w-auto object-left md:min-w-[100vw]'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden lg:block'>
|
||||
|
||||
@@ -81,6 +81,56 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureToggleItemProps {
|
||||
feature: PermissionFeature
|
||||
enabled: boolean
|
||||
color: string
|
||||
isInView: boolean
|
||||
delay: number
|
||||
textClassName: string
|
||||
transition: Record<string, unknown>
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function FeatureToggleItem({
|
||||
feature,
|
||||
enabled,
|
||||
color,
|
||||
isInView,
|
||||
delay,
|
||||
textClassName,
|
||||
transition,
|
||||
onToggle,
|
||||
}: FeatureToggleItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={`Toggle ${feature.name}`}
|
||||
aria-pressed={enabled}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ ...transition, delay }}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessControlPanel() {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-40px' })
|
||||
@@ -97,39 +147,25 @@ export function AccessControlPanel() {
|
||||
|
||||
return (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
|
||||
duration: 0.3,
|
||||
}}
|
||||
onClick={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
{category.features.map((feature, featIdx) => (
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.05 + (offsetBefore + featIdx) * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
transition={{ duration: 0.3 }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -140,12 +176,11 @@ export function AccessControlPanel() {
|
||||
<div className='hidden lg:block'>
|
||||
{PERMISSION_CATEGORIES.map((category, catIdx) => (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
const currentIndex =
|
||||
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
|
||||
(sum, c) => sum + c.features.length,
|
||||
@@ -153,30 +188,19 @@ export function AccessControlPanel() {
|
||||
) + featIdx
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.1 + currentIndex * 0.04,
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
onClick={() =>
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.1 + currentIndex * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 text-[11px] leading-none tracking-[0.02em]'>
|
||||
{timeAgo}
|
||||
</span>
|
||||
|
||||
<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]/60'> · </span>
|
||||
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* SEO:
|
||||
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
|
||||
* - `<h2 id="enterprise-heading">` for the section title.
|
||||
* - Compliance certs (SOC 2, HIPAA) as visible `<strong>` text.
|
||||
* - Compliance cert (SOC 2) as visible `<strong>` text.
|
||||
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
|
||||
*
|
||||
* GEO:
|
||||
* - Entity-rich: "Sim is SOC 2 and HIPAA compliant" — not "We are compliant."
|
||||
* - Entity-rich: "Sim is SOC 2 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?".
|
||||
*/
|
||||
@@ -66,7 +66,7 @@ const FEATURE_TAGS = [
|
||||
function TrustStrip() {
|
||||
return (
|
||||
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)] sm:grid-cols-3 md:mx-8'>
|
||||
{/* SOC 2 + HIPAA combined */}
|
||||
{/* SOC 2 */}
|
||||
<Link
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
target='_blank'
|
||||
@@ -83,10 +83,10 @@ function TrustStrip() {
|
||||
/>
|
||||
<div className='flex flex-col gap-[3px]'>
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SOC 2 & HIPAA
|
||||
SOC 2
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
|
||||
Type II · PHI protected →
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
Type II →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -105,7 +105,7 @@ function TrustStrip() {
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
Open Source
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
View on GitHub →
|
||||
</span>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ function TrustStrip() {
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SSO & SCIM
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em]'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
|
||||
Okta, Azure AD, Google
|
||||
</span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ export default function Enterprise() {
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Audit Trail
|
||||
</h3>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Every action is captured with full actor attribution.
|
||||
</p>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ export default function Enterprise() {
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Access Control
|
||||
</h3>
|
||||
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Restrict providers, surfaces, and tools per group.
|
||||
</p>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ export default function Enterprise() {
|
||||
(tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'
|
||||
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -221,7 +221,7 @@ export default function Enterprise() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
Ready for growth?
|
||||
</p>
|
||||
<DemoRequestModal>
|
||||
|
||||
@@ -190,7 +190,6 @@ export default function Features() {
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export function FooterCTA() {
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
aria-label='Submit message'
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#C0C0C0' : '#1C1C1C',
|
||||
|
||||
@@ -26,6 +26,9 @@ const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
// { label: 'Templates', href: '/templates' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Models', href: '/models' },
|
||||
// { label: 'Academy', href: '/academy' },
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
]
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function Hero() {
|
||||
1,000+ integrations and LLMs — including OpenAI, Claude, Gemini, Mistral, and xAI — to
|
||||
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
|
||||
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
|
||||
HIPAA compliant.
|
||||
SOC2 compliant.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { File } from '@/components/emcn/icons'
|
||||
import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import {
|
||||
LandingPreviewResource,
|
||||
ownerCell,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
|
||||
function AudioIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={className}
|
||||
>
|
||||
<path d='M9 18V5l12-2v13' />
|
||||
<circle cx='6' cy='18' r='3' />
|
||||
<circle cx='18' cy='16' r='3' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function JsonlIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={className}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M10 9H8' />
|
||||
<path d='M16 13H8' />
|
||||
<path d='M16 17H8' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ZipIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={className}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M10 6h1' />
|
||||
<path d='M10 10h1' />
|
||||
<path d='M10 14h1' />
|
||||
<path d='M9 18h2v2h-2z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'size', header: 'Size' },
|
||||
{ id: 'type', header: 'Type' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
]
|
||||
|
||||
const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'Q1 Performance Report.pdf' },
|
||||
size: { label: '2.4 MB' },
|
||||
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
|
||||
created: { label: '3 hours ago' },
|
||||
owner: ownerCell('T', 'Theo L.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'product-screenshots.zip' },
|
||||
size: { label: '18.7 MB' },
|
||||
type: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'ZIP' },
|
||||
created: { label: '1 day ago' },
|
||||
owner: ownerCell('A', 'Alex M.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'training-dataset.jsonl' },
|
||||
size: { label: '892 KB' },
|
||||
type: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'JSONL' },
|
||||
created: { label: '3 days ago' },
|
||||
owner: ownerCell('J', 'Jordan P.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'brand-guidelines.pdf' },
|
||||
size: { label: '5.1 MB' },
|
||||
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
|
||||
created: { label: '1 week ago' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'customer-interviews.mp3' },
|
||||
size: { label: '45.2 MB' },
|
||||
type: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'Audio' },
|
||||
created: { label: 'March 20th, 2026' },
|
||||
owner: ownerCell('V', 'Vik M.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
name: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'onboarding-playbook.docx' },
|
||||
size: { label: '1.1 MB' },
|
||||
type: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'DOCX' },
|
||||
created: { label: 'March 14th, 2026' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Static landing preview of the Files workspace page.
|
||||
*/
|
||||
export function LandingPreviewFiles() {
|
||||
return (
|
||||
<LandingPreviewResource
|
||||
icon={File}
|
||||
title='Files'
|
||||
createLabel='Upload file'
|
||||
searchPlaceholder='Search files...'
|
||||
columns={COLUMNS}
|
||||
rows={ROWS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Database } from '@/components/emcn/icons'
|
||||
import {
|
||||
AirtableIcon,
|
||||
AsanaIcon,
|
||||
ConfluenceIcon,
|
||||
GoogleDocsIcon,
|
||||
GoogleDriveIcon,
|
||||
JiraIcon,
|
||||
SalesforceIcon,
|
||||
SlackIcon,
|
||||
ZendeskIcon,
|
||||
} from '@/components/icons'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const DB_ICON = <Database className='h-[14px] w-[14px]' />
|
||||
|
||||
function connectorIcons(icons: React.ComponentType<{ className?: string }>[]) {
|
||||
return {
|
||||
content: (
|
||||
<div className='flex items-center gap-1'>
|
||||
{icons.map((Icon, i) => (
|
||||
<Icon key={i} className='h-3.5 w-3.5 flex-shrink-0' />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'documents', header: 'Documents' },
|
||||
{ id: 'tokens', header: 'Tokens' },
|
||||
{ id: 'connectors', header: 'Connectors' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
]
|
||||
|
||||
const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Product Documentation' },
|
||||
documents: { label: '847' },
|
||||
tokens: { label: '1,284,392' },
|
||||
connectors: connectorIcons([AsanaIcon, GoogleDocsIcon]),
|
||||
created: { label: '2 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Customer Support KB' },
|
||||
documents: { label: '234' },
|
||||
tokens: { label: '892,104' },
|
||||
connectors: connectorIcons([ZendeskIcon, SlackIcon]),
|
||||
created: { label: '1 week ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Engineering Wiki' },
|
||||
documents: { label: '1,203' },
|
||||
tokens: { label: '2,847,293' },
|
||||
connectors: connectorIcons([ConfluenceIcon, JiraIcon]),
|
||||
created: { label: 'March 12th, 2026' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Marketing Assets' },
|
||||
documents: { label: '189' },
|
||||
tokens: { label: '634,821' },
|
||||
connectors: connectorIcons([GoogleDriveIcon, AirtableIcon]),
|
||||
created: { label: 'March 5th, 2026' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: { icon: DB_ICON, label: 'Sales Playbook' },
|
||||
documents: { label: '92' },
|
||||
tokens: { label: '418,570' },
|
||||
connectors: connectorIcons([SalesforceIcon]),
|
||||
created: { label: 'February 28th, 2026' },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function LandingPreviewKnowledge() {
|
||||
return (
|
||||
<LandingPreviewResource
|
||||
icon={Database}
|
||||
title='Knowledge Base'
|
||||
createLabel='New base'
|
||||
searchPlaceholder='Search knowledge bases...'
|
||||
columns={COLUMNS}
|
||||
rows={ROWS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { ArrowUpDown, Badge, Library, ListFilter, Search } from '@/components/emcn'
|
||||
import type { BadgeProps } from '@/components/emcn/components/badge/badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface LogRow {
|
||||
id: string
|
||||
workflowName: string
|
||||
workflowColor: string
|
||||
date: string
|
||||
status: 'completed' | 'error' | 'running'
|
||||
cost: string
|
||||
trigger: 'webhook' | 'api' | 'schedule' | 'manual' | 'mcp' | 'chat'
|
||||
triggerLabel: string
|
||||
duration: string
|
||||
}
|
||||
|
||||
type BadgeVariant = BadgeProps['variant']
|
||||
|
||||
const STATUS_VARIANT: Record<LogRow['status'], BadgeVariant> = {
|
||||
completed: 'gray',
|
||||
error: 'red',
|
||||
running: 'amber',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<LogRow['status'], string> = {
|
||||
completed: 'Completed',
|
||||
error: 'Error',
|
||||
running: 'Running',
|
||||
}
|
||||
|
||||
const TRIGGER_VARIANT: Record<LogRow['trigger'], BadgeVariant> = {
|
||||
webhook: 'orange',
|
||||
api: 'blue',
|
||||
schedule: 'green',
|
||||
manual: 'gray-secondary',
|
||||
mcp: 'cyan',
|
||||
chat: 'purple',
|
||||
}
|
||||
|
||||
const MOCK_LOGS: LogRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
workflowName: 'Customer Onboarding',
|
||||
workflowColor: '#4f8ef7',
|
||||
date: 'Apr 1 10:42 AM',
|
||||
status: 'running',
|
||||
cost: '-',
|
||||
trigger: 'webhook',
|
||||
triggerLabel: 'Webhook',
|
||||
duration: '-',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workflowName: 'Lead Enrichment',
|
||||
workflowColor: '#33C482',
|
||||
date: 'Apr 1 09:15 AM',
|
||||
status: 'error',
|
||||
cost: '318 credits',
|
||||
trigger: 'api',
|
||||
triggerLabel: 'API',
|
||||
duration: '2.7s',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
workflowName: 'Email Campaign',
|
||||
workflowColor: '#a855f7',
|
||||
date: 'Apr 1 08:30 AM',
|
||||
status: 'completed',
|
||||
cost: '89 credits',
|
||||
trigger: 'schedule',
|
||||
triggerLabel: 'Schedule',
|
||||
duration: '0.8s',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
workflowName: 'Data Pipeline',
|
||||
workflowColor: '#f97316',
|
||||
date: 'Mar 31 10:14 PM',
|
||||
status: 'completed',
|
||||
cost: '241 credits',
|
||||
trigger: 'webhook',
|
||||
triggerLabel: 'Webhook',
|
||||
duration: '4.1s',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
workflowName: 'Invoice Processing',
|
||||
workflowColor: '#ec4899',
|
||||
date: 'Mar 31 08:45 PM',
|
||||
status: 'completed',
|
||||
cost: '112 credits',
|
||||
trigger: 'manual',
|
||||
triggerLabel: 'Manual',
|
||||
duration: '0.9s',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
workflowName: 'Support Triage',
|
||||
workflowColor: '#0ea5e9',
|
||||
date: 'Mar 31 07:22 PM',
|
||||
status: 'completed',
|
||||
cost: '197 credits',
|
||||
trigger: 'api',
|
||||
triggerLabel: 'API',
|
||||
duration: '1.6s',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
workflowName: 'Content Moderator',
|
||||
workflowColor: '#f59e0b',
|
||||
date: 'Mar 31 06:11 PM',
|
||||
status: 'error',
|
||||
cost: '284 credits',
|
||||
trigger: 'schedule',
|
||||
triggerLabel: 'Schedule',
|
||||
duration: '3.2s',
|
||||
},
|
||||
]
|
||||
|
||||
type SortKey = 'workflowName' | 'date' | 'status' | 'cost' | 'trigger' | 'duration'
|
||||
|
||||
const COL_HEADERS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'workflowName', label: 'Workflow' },
|
||||
{ key: 'date', label: 'Date' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'cost', label: 'Cost' },
|
||||
{ key: 'trigger', label: 'Trigger' },
|
||||
{ key: 'duration', label: 'Duration' },
|
||||
]
|
||||
|
||||
export function LandingPreviewLogs() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
const [activeTab, setActiveTab] = useState<'logs' | 'dashboard'>('logs')
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
const filtered = q
|
||||
? MOCK_LOGS.filter(
|
||||
(log) =>
|
||||
log.workflowName.toLowerCase().includes(q) ||
|
||||
log.triggerLabel.toLowerCase().includes(q) ||
|
||||
STATUS_LABELS[log.status].toLowerCase().includes(q)
|
||||
)
|
||||
: MOCK_LOGS
|
||||
|
||||
if (!sortKey) return filtered
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = sortKey === 'cost' ? a.cost.replace(/\D/g, '') : a[sortKey]
|
||||
const bv = sortKey === 'cost' ? b.cost.replace(/\D/g, '') : b[sortKey]
|
||||
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [search, sortKey, sortDir])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
{/* Header */}
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Library className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<h1 className='font-medium text-[var(--text-body)] text-sm'>Logs</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<Download className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Export
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className='rounded-md px-2 py-1 text-caption transition-colors'
|
||||
style={{
|
||||
backgroundColor: activeTab === 'logs' ? 'var(--surface-active)' : 'transparent',
|
||||
color: activeTab === 'logs' ? 'var(--text-body)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className='rounded-md px-2 py-1 text-caption transition-colors'
|
||||
style={{
|
||||
backgroundColor:
|
||||
activeTab === 'dashboard' ? 'var(--surface-active)' : 'transparent',
|
||||
color: activeTab === 'dashboard' ? 'var(--text-body)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options bar */}
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-2.5'>
|
||||
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder='Search logs...'
|
||||
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSort(sortKey ?? 'workflowName')}
|
||||
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table — uses <table> for pixel-perfect column alignment with headers */}
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<table className='w-full table-fixed text-sm'>
|
||||
<colgroup>
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
</colgroup>
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{COL_HEADERS.map(({ key, label }) => (
|
||||
<th
|
||||
key={key}
|
||||
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSort(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
|
||||
sortKey === key ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{sortKey === key && <ArrowUpDown className='h-[10px] w-[10px] opacity-60' />}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className='h-[44px] cursor-default transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<td className='px-6 align-middle'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: log.workflowColor,
|
||||
borderColor: `${log.workflowColor}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-caption'>
|
||||
{log.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
|
||||
{log.date}
|
||||
</td>
|
||||
<td className='px-6 align-middle'>
|
||||
<Badge variant={STATUS_VARIANT[log.status]} size='sm' dot>
|
||||
{STATUS_LABELS[log.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
|
||||
{log.cost}
|
||||
</td>
|
||||
<td className='px-6 align-middle'>
|
||||
<Badge variant={TRIGGER_VARIANT[log.trigger]} size='sm'>
|
||||
{log.triggerLabel}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
|
||||
{log.duration}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowUpDown, ListFilter, Plus, Search } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
export interface PreviewColumn {
|
||||
id: string
|
||||
header: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
export interface PreviewCell {
|
||||
icon?: ReactNode
|
||||
label?: string
|
||||
content?: ReactNode
|
||||
}
|
||||
|
||||
export interface PreviewRow {
|
||||
id: string
|
||||
cells: Record<string, PreviewCell>
|
||||
}
|
||||
|
||||
interface LandingPreviewResourceProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
createLabel: string
|
||||
searchPlaceholder: string
|
||||
columns: PreviewColumn[]
|
||||
rows: PreviewRow[]
|
||||
onRowClick?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ownerCell(initial: string, name: string): PreviewCell {
|
||||
return {
|
||||
icon: (
|
||||
<span className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{initial}
|
||||
</span>
|
||||
),
|
||||
label: name,
|
||||
}
|
||||
}
|
||||
|
||||
export function LandingPreviewResource({
|
||||
icon: Icon,
|
||||
title,
|
||||
createLabel,
|
||||
searchPlaceholder,
|
||||
columns,
|
||||
rows,
|
||||
onRowClick,
|
||||
}: LandingPreviewResourceProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortColId, setSortColId] = useState<string | null>(null)
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
function handleSortClick(colId: string) {
|
||||
if (sortColId === colId) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortColId(colId)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
const filtered = q
|
||||
? rows.filter((row) =>
|
||||
Object.values(row.cells).some((cell) => cell.label?.toLowerCase().includes(q))
|
||||
)
|
||||
: rows
|
||||
|
||||
if (!sortColId) return filtered
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = a.cells[sortColId]?.label ?? ''
|
||||
const bv = b.cells[sortColId]?.label ?? ''
|
||||
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [rows, search, sortColId, sortDir])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
{/* Header */}
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<h1 className='font-medium text-[var(--text-body)] text-sm'>{title}</h1>
|
||||
</div>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
{createLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options bar */}
|
||||
<div className='border-[var(--border)] border-b px-6 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-2.5'>
|
||||
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
|
||||
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSortClick(sortColId ?? columns[0]?.id)}
|
||||
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<table className='w-full table-fixed text-sm'>
|
||||
<colgroup>
|
||||
{columns.map((col, i) => (
|
||||
<col
|
||||
key={col.id}
|
||||
style={i === 0 ? { minWidth: col.width ?? 200 } : { width: col.width ?? 160 }}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.id}
|
||||
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleSortClick(col.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
|
||||
sortColId === col.id
|
||||
? 'text-[var(--text-secondary)]'
|
||||
: 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{col.header}
|
||||
{sortColId === col.id && (
|
||||
<ArrowUpDown className='h-[10px] w-[10px] opacity-60' />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.id)}
|
||||
className={cn(
|
||||
'transition-colors hover-hover:bg-[var(--surface-3)]',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{columns.map((col, colIdx) => {
|
||||
const cell = row.cells[col.id]
|
||||
return (
|
||||
<td key={col.id} className='px-6 py-2.5 align-middle'>
|
||||
{cell?.content ? (
|
||||
cell.content
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-3 font-medium text-sm',
|
||||
colIdx === 0
|
||||
? 'text-[var(--text-body)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{cell?.icon && (
|
||||
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||
{cell.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className='truncate'>{cell?.label ?? '—'}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Calendar } from '@/components/emcn/icons'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const CAL_ICON = <Calendar className='h-[14px] w-[14px]' />
|
||||
|
||||
const COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'task', header: 'Task' },
|
||||
{ id: 'schedule', header: 'Schedule', width: 240 },
|
||||
{ id: 'nextRun', header: 'Next Run' },
|
||||
{ id: 'lastRun', header: 'Last Run' },
|
||||
]
|
||||
|
||||
const ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Sync CRM contacts' },
|
||||
schedule: { label: 'Recurring, every day at 9:00 AM' },
|
||||
nextRun: { label: 'Tomorrow' },
|
||||
lastRun: { label: '2 hours ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Generate weekly report' },
|
||||
schedule: { label: 'Recurring, every Monday at 8:00 AM' },
|
||||
nextRun: { label: 'In 5 days' },
|
||||
lastRun: { label: '6 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Clean up stale files' },
|
||||
schedule: { label: 'Recurring, every Sunday at midnight' },
|
||||
nextRun: { label: 'In 2 days' },
|
||||
lastRun: { label: '6 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Send performance digest' },
|
||||
schedule: { label: 'Recurring, every Friday at 5:00 PM' },
|
||||
nextRun: { label: 'In 3 days' },
|
||||
lastRun: { label: '3 days ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Backup production data' },
|
||||
schedule: { label: 'Recurring, every 4 hours' },
|
||||
nextRun: { label: 'In 2 hours' },
|
||||
lastRun: { label: '2 hours ago' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
task: { icon: CAL_ICON, label: 'Scrape competitor pricing' },
|
||||
schedule: { label: 'Recurring, every Tuesday at 6:00 AM' },
|
||||
nextRun: { label: 'In 6 days' },
|
||||
lastRun: { label: '1 week ago' },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Static landing preview of the Scheduled Tasks workspace page.
|
||||
*/
|
||||
export function LandingPreviewScheduledTasks() {
|
||||
return (
|
||||
<LandingPreviewResource
|
||||
icon={Calendar}
|
||||
title='Scheduled Tasks'
|
||||
createLabel='New scheduled task'
|
||||
searchPlaceholder='Search scheduled tasks...'
|
||||
columns={COLUMNS}
|
||||
rows={ROWS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,14 +10,25 @@ import {
|
||||
Settings,
|
||||
Table,
|
||||
} from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
export type SidebarView =
|
||||
| 'home'
|
||||
| 'workflow'
|
||||
| 'tables'
|
||||
| 'files'
|
||||
| 'knowledge'
|
||||
| 'logs'
|
||||
| 'scheduled-tasks'
|
||||
|
||||
interface LandingPreviewSidebarProps {
|
||||
workflows: PreviewWorkflow[]
|
||||
activeWorkflowId: string
|
||||
activeView: 'home' | 'workflow'
|
||||
activeView: SidebarView
|
||||
onSelectWorkflow: (id: string) => void
|
||||
onSelectHome: () => void
|
||||
onSelectNav: (id: SidebarView) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +50,7 @@ const C = {
|
||||
const WORKSPACE_NAV = [
|
||||
{ id: 'tables', label: 'Tables', icon: Table },
|
||||
{ id: 'files', label: 'Files', icon: File },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'knowledge', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
] as const
|
||||
@@ -49,20 +60,42 @@ const FOOTER_NAV = [
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
function StaticNavItem({
|
||||
function NavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
|
||||
label: string
|
||||
isActive?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
if (!onClick) {
|
||||
return (
|
||||
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
|
||||
isActive && 'bg-[var(--c-active)]'
|
||||
)}
|
||||
>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,13 +110,16 @@ export function LandingPreviewSidebar({
|
||||
activeView,
|
||||
onSelectWorkflow,
|
||||
onSelectHome,
|
||||
onSelectNav,
|
||||
}: LandingPreviewSidebarProps) {
|
||||
const isHomeActive = activeView === 'home'
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-3'
|
||||
style={{ backgroundColor: C.SURFACE_1 }}
|
||||
style={
|
||||
{ backgroundColor: C.SURFACE_1, '--c-active': C.SURFACE_ACTIVE } as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0 px-2.5'>
|
||||
@@ -116,21 +152,17 @@ export function LandingPreviewSidebar({
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSelectHome}
|
||||
className='mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors'
|
||||
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
className={cn(
|
||||
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
|
||||
isHomeActive && 'bg-[var(--c-active)]'
|
||||
)}
|
||||
>
|
||||
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
Home
|
||||
</span>
|
||||
</button>
|
||||
<StaticNavItem icon={Search} label='Search' />
|
||||
<NavItem icon={Search} label='Search' />
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
@@ -142,7 +174,13 @@ export function LandingPreviewSidebar({
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5 px-2'>
|
||||
{WORKSPACE_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
<NavItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
isActive={activeView === item.id}
|
||||
onClick={() => onSelectNav(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,14 +202,10 @@ export function LandingPreviewSidebar({
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className='group mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors'
|
||||
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
className={cn(
|
||||
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[#363636]',
|
||||
isActive && 'bg-[#363636]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
|
||||
@@ -197,7 +231,7 @@ export function LandingPreviewSidebar({
|
||||
{/* Footer */}
|
||||
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
|
||||
{FOOTER_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
<NavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
import {
|
||||
ChevronDown,
|
||||
Columns3,
|
||||
Rows3,
|
||||
Table,
|
||||
TypeBoolean,
|
||||
TypeNumber,
|
||||
TypeText,
|
||||
} from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type {
|
||||
PreviewColumn,
|
||||
PreviewRow,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
import {
|
||||
LandingPreviewResource,
|
||||
ownerCell,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
|
||||
|
||||
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
|
||||
const CELL_CHECKBOX =
|
||||
'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none'
|
||||
const CELL_HEADER =
|
||||
'border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
|
||||
const CELL_HEADER_CHECKBOX =
|
||||
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
|
||||
const CELL_CONTENT =
|
||||
'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small'
|
||||
const SELECTION_OVERLAY =
|
||||
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]'
|
||||
|
||||
const LIST_COLUMNS: PreviewColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'columns', header: 'Columns' },
|
||||
{ id: 'rows', header: 'Rows' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
]
|
||||
|
||||
const TABLE_METAS: Record<string, string> = {
|
||||
'1': 'Customer Leads',
|
||||
'2': 'Product Catalog',
|
||||
'3': 'Campaign Analytics',
|
||||
'4': 'User Profiles',
|
||||
'5': 'Invoice Records',
|
||||
}
|
||||
|
||||
const TABLE_ICON = <Table className='h-[14px] w-[14px]' />
|
||||
const COLUMNS_ICON = <Columns3 className='h-[14px] w-[14px]' />
|
||||
const ROWS_ICON = <Rows3 className='h-[14px] w-[14px]' />
|
||||
|
||||
const LIST_ROWS: PreviewRow[] = [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Customer Leads' },
|
||||
columns: { icon: COLUMNS_ICON, label: '8' },
|
||||
rows: { icon: ROWS_ICON, label: '2,847' },
|
||||
created: { label: '2 days ago' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Product Catalog' },
|
||||
columns: { icon: COLUMNS_ICON, label: '12' },
|
||||
rows: { icon: ROWS_ICON, label: '1,203' },
|
||||
created: { label: '5 days ago' },
|
||||
owner: ownerCell('A', 'Alex M.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Campaign Analytics' },
|
||||
columns: { icon: COLUMNS_ICON, label: '6' },
|
||||
rows: { icon: ROWS_ICON, label: '534' },
|
||||
created: { label: '1 week ago' },
|
||||
owner: ownerCell('W', 'Emaan K.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'User Profiles' },
|
||||
columns: { icon: COLUMNS_ICON, label: '15' },
|
||||
rows: { icon: ROWS_ICON, label: '18,492' },
|
||||
created: { label: '2 weeks ago' },
|
||||
owner: ownerCell('J', 'Jordan P.'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: { icon: TABLE_ICON, label: 'Invoice Records' },
|
||||
columns: { icon: COLUMNS_ICON, label: '9' },
|
||||
rows: { icon: ROWS_ICON, label: '742' },
|
||||
created: { label: 'March 15th, 2026' },
|
||||
owner: ownerCell('S', 'Sarah K.'),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface SpreadsheetColumn {
|
||||
id: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'boolean'
|
||||
width: number
|
||||
}
|
||||
|
||||
interface SpreadsheetRow {
|
||||
id: string
|
||||
cells: Record<string, string>
|
||||
}
|
||||
|
||||
const COLUMN_TYPE_ICONS = {
|
||||
text: TypeText,
|
||||
number: TypeNumber,
|
||||
boolean: TypeBoolean,
|
||||
} as const
|
||||
|
||||
const SPREADSHEET_DATA: Record<string, { columns: SpreadsheetColumn[]; rows: SpreadsheetRow[] }> = {
|
||||
'1': {
|
||||
columns: [
|
||||
{ id: 'name', label: 'Name', type: 'text', width: 160 },
|
||||
{ id: 'email', label: 'Email', type: 'text', width: 200 },
|
||||
{ id: 'company', label: 'Company', type: 'text', width: 160 },
|
||||
{ id: 'score', label: 'Score', type: 'number', width: 100 },
|
||||
{ id: 'qualified', label: 'Qualified', type: 'boolean', width: 120 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
name: 'Alice Johnson',
|
||||
email: 'alice@acme.com',
|
||||
company: 'Acme Corp',
|
||||
score: '87',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
name: 'Bob Williams',
|
||||
email: 'bob@techco.io',
|
||||
company: 'TechCo',
|
||||
score: '62',
|
||||
qualified: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
name: 'Carol Davis',
|
||||
email: 'carol@startup.co',
|
||||
company: 'StartupCo',
|
||||
score: '94',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
name: 'Dan Miller',
|
||||
email: 'dan@bigcorp.com',
|
||||
company: 'BigCorp',
|
||||
score: '71',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
name: 'Eva Chen',
|
||||
email: 'eva@design.io',
|
||||
company: 'Design IO',
|
||||
score: '45',
|
||||
qualified: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
name: 'Frank Lee',
|
||||
email: 'frank@ventures.co',
|
||||
company: 'Ventures',
|
||||
score: '88',
|
||||
qualified: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'2': {
|
||||
columns: [
|
||||
{ id: 'sku', label: 'SKU', type: 'text', width: 120 },
|
||||
{ id: 'name', label: 'Product Name', type: 'text', width: 200 },
|
||||
{ id: 'price', label: 'Price', type: 'number', width: 100 },
|
||||
{ id: 'stock', label: 'In Stock', type: 'number', width: 120 },
|
||||
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
sku: 'PRD-001',
|
||||
name: 'Wireless Headphones',
|
||||
price: '79.99',
|
||||
stock: '234',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: { sku: 'PRD-002', name: 'USB-C Hub', price: '49.99', stock: '89', active: 'true' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
sku: 'PRD-003',
|
||||
name: 'Laptop Stand',
|
||||
price: '39.99',
|
||||
stock: '0',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
sku: 'PRD-004',
|
||||
name: 'Mechanical Keyboard',
|
||||
price: '129.99',
|
||||
stock: '52',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: { sku: 'PRD-005', name: 'Webcam HD', price: '89.99', stock: '17', active: 'true' },
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
sku: 'PRD-006',
|
||||
name: 'Mouse Pad XL',
|
||||
price: '24.99',
|
||||
stock: '0',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'3': {
|
||||
columns: [
|
||||
{ id: 'campaign', label: 'Campaign', type: 'text', width: 180 },
|
||||
{ id: 'clicks', label: 'Clicks', type: 'number', width: 100 },
|
||||
{ id: 'conversions', label: 'Conversions', type: 'number', width: 140 },
|
||||
{ id: 'spend', label: 'Spend ($)', type: 'number', width: 130 },
|
||||
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
campaign: 'Spring Sale 2026',
|
||||
clicks: '12,847',
|
||||
conversions: '384',
|
||||
spend: '2,400',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
campaign: 'Email Reactivation',
|
||||
clicks: '3,201',
|
||||
conversions: '97',
|
||||
spend: '450',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
campaign: 'Referral Program',
|
||||
clicks: '8,923',
|
||||
conversions: '210',
|
||||
spend: '1,100',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
campaign: 'Product Launch',
|
||||
clicks: '24,503',
|
||||
conversions: '891',
|
||||
spend: '5,800',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
campaign: 'Retargeting Q1',
|
||||
clicks: '6,712',
|
||||
conversions: '143',
|
||||
spend: '980',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'4': {
|
||||
columns: [
|
||||
{ id: 'username', label: 'Username', type: 'text', width: 140 },
|
||||
{ id: 'email', label: 'Email', type: 'text', width: 200 },
|
||||
{ id: 'plan', label: 'Plan', type: 'text', width: 120 },
|
||||
{ id: 'seats', label: 'Seats', type: 'number', width: 100 },
|
||||
{ id: 'active', label: 'Active', type: 'boolean', width: 100 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: {
|
||||
username: 'alice_j',
|
||||
email: 'alice@acme.com',
|
||||
plan: 'Pro',
|
||||
seats: '5',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: {
|
||||
username: 'bobw',
|
||||
email: 'bob@techco.io',
|
||||
plan: 'Starter',
|
||||
seats: '1',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: {
|
||||
username: 'carol_d',
|
||||
email: 'carol@startup.co',
|
||||
plan: 'Enterprise',
|
||||
seats: '25',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: {
|
||||
username: 'dan.m',
|
||||
email: 'dan@bigcorp.com',
|
||||
plan: 'Pro',
|
||||
seats: '10',
|
||||
active: 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: {
|
||||
username: 'eva_chen',
|
||||
email: 'eva@design.io',
|
||||
plan: 'Starter',
|
||||
seats: '1',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
cells: {
|
||||
username: 'frank_lee',
|
||||
email: 'frank@ventures.co',
|
||||
plan: 'Enterprise',
|
||||
seats: '50',
|
||||
active: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'5': {
|
||||
columns: [
|
||||
{ id: 'invoice', label: 'Invoice #', type: 'text', width: 140 },
|
||||
{ id: 'client', label: 'Client', type: 'text', width: 160 },
|
||||
{ id: 'amount', label: 'Amount ($)', type: 'number', width: 130 },
|
||||
{ id: 'paid', label: 'Paid', type: 'boolean', width: 80 },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
cells: { invoice: 'INV-2026-001', client: 'Acme Corp', amount: '4,800.00', paid: 'true' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
cells: { invoice: 'INV-2026-002', client: 'TechCo', amount: '1,200.00', paid: 'true' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
cells: { invoice: 'INV-2026-003', client: 'StartupCo', amount: '750.00', paid: 'false' },
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
cells: { invoice: 'INV-2026-004', client: 'BigCorp', amount: '12,500.00', paid: 'true' },
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
cells: { invoice: 'INV-2026-005', client: 'Design IO', amount: '3,300.00', paid: 'false' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
interface SpreadsheetViewProps {
|
||||
tableId: string
|
||||
tableName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
|
||||
const data = SPREADSHEET_DATA[tableId] ?? SPREADSHEET_DATA['1']
|
||||
const [selectedCell, setSelectedCell] = useState<{ row: string; col: string } | null>(null)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
{/* Breadcrumb header — matches real ResourceHeader breadcrumb layout */}
|
||||
<div className='border-[var(--border)] border-b px-4 py-[8.5px]'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onBack}
|
||||
className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-secondary)] text-sm transition-colors hover-hover:text-[var(--text-body)]'
|
||||
>
|
||||
<Table className='mr-3 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Tables
|
||||
</button>
|
||||
<span className='select-none text-[var(--text-icon)] text-sm'>/</span>
|
||||
<span className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-body)] text-sm'>
|
||||
{tableName}
|
||||
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spreadsheet — matches exact real table editor structure */}
|
||||
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
|
||||
<table className='table-fixed border-separate border-spacing-0 text-small'>
|
||||
<colgroup>
|
||||
<col style={{ width: 40 }} />
|
||||
{data.columns.map((col) => (
|
||||
<col key={col.id} style={{ width: col.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className='sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className={CELL_HEADER_CHECKBOX} />
|
||||
{data.columns.map((col) => {
|
||||
const Icon = COLUMN_TYPE_ICONS[col.type] ?? TypeText
|
||||
return (
|
||||
<th key={col.id} className={CELL_HEADER}>
|
||||
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
|
||||
<Icon className='h-3 w-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
|
||||
{col.label}
|
||||
</span>
|
||||
<ChevronDown className='ml-auto h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.rows.map((row, rowIdx) => (
|
||||
<tr key={row.id}>
|
||||
<td className={cn(CELL_CHECKBOX, 'text-center')}>
|
||||
<span className='text-[var(--text-tertiary)] text-xs tabular-nums'>
|
||||
{rowIdx + 1}
|
||||
</span>
|
||||
</td>
|
||||
{data.columns.map((col) => {
|
||||
const isSelected = selectedCell?.row === row.id && selectedCell?.col === col.id
|
||||
const cellValue = row.cells[col.id] ?? ''
|
||||
return (
|
||||
<td
|
||||
key={col.id}
|
||||
onClick={() => setSelectedCell({ row: row.id, col: col.id })}
|
||||
className={cn(
|
||||
CELL,
|
||||
'relative cursor-default text-[var(--text-body)]',
|
||||
isSelected && 'bg-[rgba(37,99,235,0.06)]'
|
||||
)}
|
||||
>
|
||||
{isSelected && <div className={SELECTION_OVERLAY} />}
|
||||
<div className={CELL_CONTENT}>
|
||||
{col.type === 'boolean' ? (
|
||||
<div className='flex min-h-[20px] items-center justify-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={cellValue === 'true'}
|
||||
className='pointer-events-none'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
cellValue
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LandingPreviewTables() {
|
||||
const [openTableId, setOpenTableId] = useState<string | null>(null)
|
||||
|
||||
if (openTableId !== null) {
|
||||
return (
|
||||
<SpreadsheetView
|
||||
tableId={openTableId}
|
||||
tableName={TABLE_METAS[openTableId] ?? 'Table'}
|
||||
onBack={() => setOpenTableId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingPreviewResource
|
||||
icon={Table}
|
||||
title='Tables'
|
||||
createLabel='New table'
|
||||
searchPlaceholder='Search tables...'
|
||||
columns={LIST_COLUMNS}
|
||||
rows={LIST_ROWS}
|
||||
onRowClick={(id) => setOpenTableId(id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -228,13 +228,13 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-2'>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-2 py-1'
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||
>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
|
||||
@@ -127,6 +127,60 @@ const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Support Agent workflow — Gmail Trigger -> Agent (KB + Notion tools) -> Slack
|
||||
*/
|
||||
const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-customer-support',
|
||||
name: 'Customer Support Agent',
|
||||
color: '#0EA5E9',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmail-1',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Email' },
|
||||
{ title: 'Label', value: 'Support' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-3',
|
||||
name: 'Support Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.4' },
|
||||
{ title: 'System Prompt', value: 'Resolve customer issues...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Knowledge', type: 'knowledge_base', bgColor: '#10B981' },
|
||||
{ name: 'Notion', type: 'notion', bgColor: '#181C1E' },
|
||||
],
|
||||
position: { x: 420, y: 40 },
|
||||
},
|
||||
{
|
||||
id: 'slack-3',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#support' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 420, y: 260 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-cs-1', source: 'gmail-1', target: 'agent-3' },
|
||||
{ id: 'e-cs-2', source: 'gmail-1', target: 'slack-3' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty "New Agent" workflow — a single note prompting the user to start building
|
||||
*/
|
||||
@@ -153,6 +207,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
SELF_HEALING_CRM_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
CUSTOMER_SUPPORT_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewFiles } from '@/app/(home)/components/landing-preview/components/landing-preview-files/landing-preview-files'
|
||||
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
|
||||
import { LandingPreviewKnowledge } from '@/app/(home)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
|
||||
import { LandingPreviewLogs } from '@/app/(home)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
|
||||
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { LandingPreviewScheduledTasks } from '@/app/(home)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
|
||||
import type { SidebarView } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewTables } from '@/app/(home)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
|
||||
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
import {
|
||||
EASE_OUT,
|
||||
@@ -46,18 +52,16 @@ const panelVariants: Variants = {
|
||||
* Interactive workspace preview for the hero section.
|
||||
*
|
||||
* Renders a lightweight replica of the Sim workspace with:
|
||||
* - A sidebar with two selectable workflows
|
||||
* - A sidebar with selectable workflows and workspace nav items
|
||||
* - A ReactFlow canvas showing the active workflow's blocks and edges
|
||||
* - Static previews of Tables, Files, Knowledge Base, Logs, and Scheduled Tasks
|
||||
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
|
||||
*
|
||||
* Everything except the workflow items and the copilot input is non-interactive.
|
||||
* On mount the sidebar slides from left and the panel from right. The canvas
|
||||
* background stays fully opaque; individual block nodes animate in with a
|
||||
* staggered fade. Edges draw left-to-right. Animations only fire on initial
|
||||
* load — workflow switches render instantly.
|
||||
* Only workflow items, the home button, workspace nav items, and the copilot input
|
||||
* are interactive. Animations only fire on initial load.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
|
||||
const [activeView, setActiveView] = useState<SidebarView>('workflow')
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const isInitialMount = useRef(true)
|
||||
|
||||
@@ -74,11 +78,34 @@ export function LandingPreview() {
|
||||
setActiveView('home')
|
||||
}, [])
|
||||
|
||||
const handleSelectNav = useCallback((id: SidebarView) => {
|
||||
setActiveView(id)
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
const isWorkflowView = activeView === 'workflow'
|
||||
|
||||
function renderContent() {
|
||||
switch (activeView) {
|
||||
case 'workflow':
|
||||
return <LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
case 'home':
|
||||
return <LandingPreviewHome />
|
||||
case 'tables':
|
||||
return <LandingPreviewTables />
|
||||
case 'files':
|
||||
return <LandingPreviewFiles />
|
||||
case 'knowledge':
|
||||
return <LandingPreviewKnowledge />
|
||||
case 'logs':
|
||||
return <LandingPreviewLogs />
|
||||
case 'scheduled-tasks':
|
||||
return <LandingPreviewScheduledTasks />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
|
||||
@@ -93,6 +120,7 @@ export function LandingPreview() {
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
|
||||
@@ -104,11 +132,7 @@ export function LandingPreview() {
|
||||
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
{isWorkflowView ? (
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
) : (
|
||||
<LandingPreviewHome />
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
<motion.div
|
||||
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
|
||||
|
||||
@@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'5GB file storage',
|
||||
'3 tables · 1,000 rows each',
|
||||
'5 min execution limit',
|
||||
'5 concurrent/workspace',
|
||||
'7-day log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'50GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 150 runs/min',
|
||||
'50 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'500GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 300 runs/min',
|
||||
'200 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -75,8 +78,9 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'Custom file storage',
|
||||
'10,000 tables · 1M rows each',
|
||||
'Custom execution limits',
|
||||
'Custom concurrency limits',
|
||||
'Unlimited log retention',
|
||||
'SSO & SCIM · SOC2 & HIPAA',
|
||||
'SSO & SCIM · SOC2',
|
||||
'Self hosting · Dedicated support',
|
||||
],
|
||||
cta: { label: 'Book a demo', action: 'demo-request' },
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function StructuredData() {
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 compliant.',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
browserRequirements: 'Requires a modern browser with JavaScript enabled',
|
||||
@@ -179,7 +179,7 @@ export default function StructuredData() {
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -211,7 +211,7 @@ export default function StructuredData() {
|
||||
name: 'What enterprise features does Sim offer?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||
text: 'Sim offers SOC2 compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Copy } from '@/components/emcn/icons'
|
||||
import { LinkedInIcon, xIcon as XIcon } from '@/components/icons'
|
||||
|
||||
interface ShareButtonProps {
|
||||
url: string
|
||||
@@ -50,10 +52,17 @@ export function ShareButton({ url, title }: ShareButtonProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={handleCopyLink}>
|
||||
<Copy className='h-4 w-4' />
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareTwitter}>Share on X</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareLinkedIn}>Share on LinkedIn</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareTwitter}>
|
||||
<XIcon className='h-4 w-4' />
|
||||
Share on X
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareLinkedIn}>
|
||||
<LinkedInIcon className='h-4 w-4' />
|
||||
Share on LinkedIn
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
|
||||
|
||||
interface BlogImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
onClick={() => setIsLightboxOpen(true)}
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-label='Image viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
apps/sim/app/(landing)/components/landing-faq.tsx
Normal file
63
apps/sim/app/(landing)/components/landing-faq.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
export interface LandingFAQItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
interface LandingFAQProps {
|
||||
faqs: LandingFAQItem[]
|
||||
}
|
||||
|
||||
export function LandingFAQ({ faqs }: LandingFAQProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<div className='divide-y divide-[var(--landing-border)]'>
|
||||
{faqs.map(({ question, answer }, index) => {
|
||||
const isOpen = openIndex === index
|
||||
|
||||
return (
|
||||
<div key={question}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||
className='flex w-full items-start justify-between gap-4 py-5 text-left'
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'font-[500] text-[15px] leading-snug transition-colors',
|
||||
isOpen
|
||||
? 'text-[var(--landing-text)]'
|
||||
: 'text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0 text-[#555] transition-transform duration-200',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className='pb-5'>
|
||||
<p className='text-[14px] text-[var(--landing-text-muted)] leading-[1.75]'>
|
||||
{answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import type { FAQItem } from '@/app/(landing)/integrations/data/types'
|
||||
|
||||
interface IntegrationFAQProps {
|
||||
@@ -10,49 +6,5 @@ interface IntegrationFAQProps {
|
||||
}
|
||||
|
||||
export function IntegrationFAQ({ faqs }: IntegrationFAQProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<div className='divide-y divide-[var(--landing-border)]'>
|
||||
{faqs.map(({ question, answer }, index) => {
|
||||
const isOpen = openIndex === index
|
||||
return (
|
||||
<div key={question}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||
className='flex w-full items-start justify-between gap-4 py-5 text-left'
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'font-[500] text-[15px] leading-snug transition-colors',
|
||||
isOpen
|
||||
? 'text-[var(--landing-text)]'
|
||||
: 'text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0 text-[#555] transition-transform duration-200',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className='pb-5'>
|
||||
<p className='text-[14px] text-[var(--landing-text-muted)] leading-[1.75]'>
|
||||
{answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
return <LandingFAQ faqs={faqs} />
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
EnrichSoIcon,
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
ExtendIcon,
|
||||
EyeIcon,
|
||||
FathomIcon,
|
||||
FirecrawlIcon,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
KalshiIcon,
|
||||
KetchIcon,
|
||||
LangsmithIcon,
|
||||
LaunchDarklyIcon,
|
||||
LemlistIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
@@ -126,6 +128,7 @@ import {
|
||||
PolymarketIcon,
|
||||
PostgresIcon,
|
||||
PosthogIcon,
|
||||
ProfoundIcon,
|
||||
PulseIcon,
|
||||
QdrantIcon,
|
||||
QuiverIcon,
|
||||
@@ -136,9 +139,11 @@ import {
|
||||
ResendIcon,
|
||||
RevenueCatIcon,
|
||||
RipplingIcon,
|
||||
RootlyIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
SearchIcon,
|
||||
SecretsManagerIcon,
|
||||
SendgridIcon,
|
||||
SentryIcon,
|
||||
SerperIcon,
|
||||
@@ -154,6 +159,7 @@ import {
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TailscaleIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
@@ -220,6 +226,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
enrich: EnrichSoIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
extend_v2: ExtendIcon,
|
||||
fathom: FathomIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
@@ -268,6 +275,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
ketch: KetchIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
langsmith: LangsmithIcon,
|
||||
launchdarkly: LaunchDarklyIcon,
|
||||
lemlist: LemlistIcon,
|
||||
linear: LinearIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
@@ -302,6 +310,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
profound: ProfoundIcon,
|
||||
pulse_v2: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
quiver: QuiverIcon,
|
||||
@@ -312,9 +321,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
resend: ResendIcon,
|
||||
revenuecat: RevenueCatIcon,
|
||||
rippling: RipplingIcon,
|
||||
rootly: RootlyIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
search: SearchIcon,
|
||||
secrets_manager: SecretsManagerIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
@@ -331,6 +342,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
stripe: StripeIcon,
|
||||
stt_v2: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
tailscale: TailscaleIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
textract_v2: TextractIcon,
|
||||
|
||||
@@ -926,6 +926,10 @@
|
||||
"name": "List Tasks",
|
||||
"description": "List tasks in Attio, optionally filtered by record, assignee, or completion status"
|
||||
},
|
||||
{
|
||||
"name": "Get Task",
|
||||
"description": "Get a single task by ID from Attio"
|
||||
},
|
||||
{
|
||||
"name": "Create Task",
|
||||
"description": "Create a task in Attio"
|
||||
@@ -1039,7 +1043,7 @@
|
||||
"description": "Delete a webhook from Attio"
|
||||
}
|
||||
],
|
||||
"operationCount": 40,
|
||||
"operationCount": 41,
|
||||
"triggers": [
|
||||
{
|
||||
"id": "attio_record_created",
|
||||
@@ -1126,18 +1130,77 @@
|
||||
"name": "Attio List Entry Deleted",
|
||||
"description": "Trigger workflow when a list entry is deleted in Attio"
|
||||
},
|
||||
{
|
||||
"id": "attio_list_created",
|
||||
"name": "Attio List Created",
|
||||
"description": "Trigger workflow when a list is created in Attio"
|
||||
},
|
||||
{
|
||||
"id": "attio_list_updated",
|
||||
"name": "Attio List Updated",
|
||||
"description": "Trigger workflow when a list is updated in Attio"
|
||||
},
|
||||
{
|
||||
"id": "attio_list_deleted",
|
||||
"name": "Attio List Deleted",
|
||||
"description": "Trigger workflow when a list is deleted in Attio"
|
||||
},
|
||||
{
|
||||
"id": "attio_workspace_member_created",
|
||||
"name": "Attio Workspace Member Created",
|
||||
"description": "Trigger workflow when a new member is added to the Attio workspace"
|
||||
},
|
||||
{
|
||||
"id": "attio_webhook",
|
||||
"name": "Attio Webhook (All Events)",
|
||||
"description": "Trigger workflow on any Attio webhook event"
|
||||
}
|
||||
],
|
||||
"triggerCount": 18,
|
||||
"triggerCount": 22,
|
||||
"authType": "oauth",
|
||||
"category": "tools",
|
||||
"integrationType": "crm",
|
||||
"tags": ["sales-engagement", "enrichment"]
|
||||
},
|
||||
{
|
||||
"type": "secrets_manager",
|
||||
"slug": "aws-secrets-manager",
|
||||
"name": "AWS Secrets Manager",
|
||||
"description": "Connect to AWS Secrets Manager",
|
||||
"longDescription": "Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.",
|
||||
"bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)",
|
||||
"iconName": "SecretsManagerIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/secrets-manager",
|
||||
"operations": [
|
||||
{
|
||||
"name": "Get Secret",
|
||||
"description": "Retrieve a secret value from AWS Secrets Manager"
|
||||
},
|
||||
{
|
||||
"name": "List Secrets",
|
||||
"description": "List secrets stored in AWS Secrets Manager"
|
||||
},
|
||||
{
|
||||
"name": "Create Secret",
|
||||
"description": "Create a new secret in AWS Secrets Manager"
|
||||
},
|
||||
{
|
||||
"name": "Update Secret",
|
||||
"description": "Update the value of an existing secret in AWS Secrets Manager"
|
||||
},
|
||||
{
|
||||
"name": "Delete Secret",
|
||||
"description": "Delete a secret from AWS Secrets Manager"
|
||||
}
|
||||
],
|
||||
"operationCount": 5,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
"category": "tools",
|
||||
"integrationType": "developer-tools",
|
||||
"tags": ["cloud", "secrets-management"]
|
||||
},
|
||||
{
|
||||
"type": "textract_v2",
|
||||
"slug": "aws-textract",
|
||||
@@ -2939,6 +3002,24 @@
|
||||
"integrationType": "search",
|
||||
"tags": ["web-scraping", "enrichment"]
|
||||
},
|
||||
{
|
||||
"type": "extend_v2",
|
||||
"slug": "extend",
|
||||
"name": "Extend",
|
||||
"description": "Parse and extract content from documents",
|
||||
"longDescription": "Integrate Extend AI into the workflow. Parse and extract structured content from documents or file references.",
|
||||
"bgColor": "#000000",
|
||||
"iconName": "ExtendIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/extend",
|
||||
"operations": [],
|
||||
"operationCount": 0,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "ai",
|
||||
"tags": ["document-processing", "ocr"]
|
||||
},
|
||||
{
|
||||
"type": "fathom",
|
||||
"slug": "fathom",
|
||||
@@ -2993,13 +3074,26 @@
|
||||
"type": "file_v3",
|
||||
"slug": "file",
|
||||
"name": "File",
|
||||
"description": "Read and parse multiple files",
|
||||
"longDescription": "Upload files directly or import from external URLs to get UserFile objects for use in other blocks.",
|
||||
"description": "Read and write workspace files",
|
||||
"longDescription": "Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.",
|
||||
"bgColor": "#40916C",
|
||||
"iconName": "DocumentIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/file",
|
||||
"operations": [],
|
||||
"operationCount": 0,
|
||||
"operations": [
|
||||
{
|
||||
"name": "Read",
|
||||
"description": "Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)"
|
||||
},
|
||||
{
|
||||
"name": "Write",
|
||||
"description": "Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., "
|
||||
},
|
||||
{
|
||||
"name": "Append",
|
||||
"description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file."
|
||||
}
|
||||
],
|
||||
"operationCount": 3,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
@@ -6287,6 +6381,73 @@
|
||||
"integrationType": "developer-tools",
|
||||
"tags": ["monitoring", "llm", "data-analytics"]
|
||||
},
|
||||
{
|
||||
"type": "launchdarkly",
|
||||
"slug": "launchdarkly",
|
||||
"name": "LaunchDarkly",
|
||||
"description": "Manage feature flags with LaunchDarkly.",
|
||||
"longDescription": "Integrate LaunchDarkly into your workflow. List, create, update, toggle, and delete feature flags. Manage projects, environments, segments, members, and audit logs. Requires API Key.",
|
||||
"bgColor": "#191919",
|
||||
"iconName": "LaunchDarklyIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/launchdarkly",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List Flags",
|
||||
"description": "List feature flags in a LaunchDarkly project."
|
||||
},
|
||||
{
|
||||
"name": "Get Flag",
|
||||
"description": "Get a single feature flag by key from a LaunchDarkly project."
|
||||
},
|
||||
{
|
||||
"name": "Create Flag",
|
||||
"description": "Create a new feature flag in a LaunchDarkly project."
|
||||
},
|
||||
{
|
||||
"name": "Update Flag",
|
||||
"description": "Update a feature flag metadata (name, description, tags, temporary, archived) using semantic patch."
|
||||
},
|
||||
{
|
||||
"name": "Toggle Flag",
|
||||
"description": "Toggle a feature flag on or off in a specific LaunchDarkly environment."
|
||||
},
|
||||
{
|
||||
"name": "Delete Flag",
|
||||
"description": "Delete a feature flag from a LaunchDarkly project."
|
||||
},
|
||||
{
|
||||
"name": "Get Flag Status",
|
||||
"description": "Get the status of a feature flag across environments (active, inactive, launched, etc.)."
|
||||
},
|
||||
{
|
||||
"name": "List Projects",
|
||||
"description": "List all projects in your LaunchDarkly account."
|
||||
},
|
||||
{
|
||||
"name": "List Environments",
|
||||
"description": "List environments in a LaunchDarkly project."
|
||||
},
|
||||
{
|
||||
"name": "List Segments",
|
||||
"description": "List user segments in a LaunchDarkly project and environment."
|
||||
},
|
||||
{
|
||||
"name": "List Members",
|
||||
"description": "List account members in your LaunchDarkly organization."
|
||||
},
|
||||
{
|
||||
"name": "Get Audit Log",
|
||||
"description": "List audit log entries from your LaunchDarkly account."
|
||||
}
|
||||
],
|
||||
"operationCount": 12,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "developer-tools",
|
||||
"tags": ["feature-flags", "ci-cd"]
|
||||
},
|
||||
{
|
||||
"type": "lemlist",
|
||||
"slug": "lemlist",
|
||||
@@ -8611,6 +8772,121 @@
|
||||
"integrationType": "analytics",
|
||||
"tags": ["data-analytics", "monitoring"]
|
||||
},
|
||||
{
|
||||
"type": "profound",
|
||||
"slug": "profound",
|
||||
"name": "Profound",
|
||||
"description": "AI visibility and analytics with Profound",
|
||||
"longDescription": "Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.",
|
||||
"bgColor": "#000000",
|
||||
"iconName": "ProfoundIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/profound",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List Categories",
|
||||
"description": "List all organization categories in Profound"
|
||||
},
|
||||
{
|
||||
"name": "List Regions",
|
||||
"description": "List all organization regions in Profound"
|
||||
},
|
||||
{
|
||||
"name": "List Models",
|
||||
"description": "List all AI models/platforms tracked in Profound"
|
||||
},
|
||||
{
|
||||
"name": "List Domains",
|
||||
"description": "List all organization domains in Profound"
|
||||
},
|
||||
{
|
||||
"name": "List Assets",
|
||||
"description": "List all organization assets (companies/brands) across all categories in Profound"
|
||||
},
|
||||
{
|
||||
"name": "List Personas",
|
||||
"description": "List all organization personas across all categories in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Category Topics",
|
||||
"description": "List topics for a specific category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Category Tags",
|
||||
"description": "List tags for a specific category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Category Prompts",
|
||||
"description": "List prompts for a specific category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Category Assets",
|
||||
"description": "List assets (companies/brands) for a specific category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Category Personas",
|
||||
"description": "List personas for a specific category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Visibility Report",
|
||||
"description": "Query AI visibility report for a category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Sentiment Report",
|
||||
"description": "Query sentiment report for a category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Citations Report",
|
||||
"description": "Query citations report for a category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Query Fanouts",
|
||||
"description": "Query fanout report showing how AI models expand prompts into sub-queries in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Prompt Answers",
|
||||
"description": "Get raw prompt answers data for a category in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Bots Report",
|
||||
"description": "Query bot traffic report with hourly granularity for a domain in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Referrals Report",
|
||||
"description": "Query human referral traffic report with hourly granularity for a domain in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Raw Logs",
|
||||
"description": "Get raw traffic logs with filters for a domain in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Bot Logs",
|
||||
"description": "Get identified bot visit logs with filters for a domain in Profound"
|
||||
},
|
||||
{
|
||||
"name": "List Optimizations",
|
||||
"description": "List content optimization entries for an asset in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Optimization Analysis",
|
||||
"description": "Get detailed content optimization analysis for a specific content item in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Prompt Volume",
|
||||
"description": "Query prompt volume data to understand search demand across AI platforms in Profound"
|
||||
},
|
||||
{
|
||||
"name": "Citation Prompts",
|
||||
"description": "Get prompts that cite a specific domain across AI platforms in Profound"
|
||||
}
|
||||
],
|
||||
"operationCount": 24,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "analytics",
|
||||
"tags": ["seo", "data-analytics"]
|
||||
},
|
||||
{
|
||||
"type": "pulse_v2",
|
||||
"slug": "pulse",
|
||||
@@ -9017,90 +9293,358 @@
|
||||
"type": "rippling",
|
||||
"slug": "rippling",
|
||||
"name": "Rippling",
|
||||
"description": "Manage employees, leave, departments, and company data in Rippling",
|
||||
"longDescription": "Integrate Rippling into your workflow. Manage employees, departments, teams, leave requests, work locations, groups, candidates, and company information.",
|
||||
"description": "Manage workers, departments, custom objects, and company data in Rippling",
|
||||
"longDescription": "Integrate Rippling Platform into your workflow. Manage workers, users, departments, teams, titles, work locations, business partners, supergroups, custom objects, custom apps, custom pages, custom settings, object categories, reports, and draft hires.",
|
||||
"bgColor": "#FFCC1C",
|
||||
"iconName": "RipplingIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/rippling",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List Employees",
|
||||
"description": "List all employees in Rippling with optional pagination"
|
||||
"name": "List Workers",
|
||||
"description": "List all workers with optional filtering and pagination"
|
||||
},
|
||||
{
|
||||
"name": "Get Employee",
|
||||
"description": "Get details for a specific employee by ID"
|
||||
"name": "Get Worker",
|
||||
"description": "Get a specific worker by ID"
|
||||
},
|
||||
{
|
||||
"name": "List Employees (Including Terminated)",
|
||||
"description": "List all employees in Rippling including terminated employees with optional pagination"
|
||||
"name": "List Users",
|
||||
"description": "List all users with optional pagination"
|
||||
},
|
||||
{
|
||||
"name": "List Departments",
|
||||
"description": "List all departments in the Rippling organization"
|
||||
"name": "Get User",
|
||||
"description": "Get a specific user by ID"
|
||||
},
|
||||
{
|
||||
"name": "List Teams",
|
||||
"description": "List all teams in Rippling"
|
||||
},
|
||||
{
|
||||
"name": "List Levels",
|
||||
"description": "List all position levels in Rippling"
|
||||
},
|
||||
{
|
||||
"name": "List Work Locations",
|
||||
"description": "List all work locations in Rippling"
|
||||
},
|
||||
{
|
||||
"name": "Get Company",
|
||||
"description": "Get details for the current company in Rippling"
|
||||
},
|
||||
{
|
||||
"name": "Get Company Activity",
|
||||
"description": "Get activity events for the current company in Rippling"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Fields",
|
||||
"description": "List all custom fields defined in Rippling"
|
||||
"name": "List Companies",
|
||||
"description": "List all companies"
|
||||
},
|
||||
{
|
||||
"name": "Get Current User",
|
||||
"description": "Get the current authenticated user details"
|
||||
"description": "Get SSO information for the current user"
|
||||
},
|
||||
{
|
||||
"name": "List Leave Requests",
|
||||
"description": "List leave requests in Rippling with optional filtering by date range and status"
|
||||
"name": "List Entitlements",
|
||||
"description": "List all entitlements"
|
||||
},
|
||||
{
|
||||
"name": "Approve/Decline Leave Request",
|
||||
"description": "Approve or decline a leave request in Rippling"
|
||||
"name": "List Departments",
|
||||
"description": "List all departments"
|
||||
},
|
||||
{
|
||||
"name": "List Leave Balances",
|
||||
"description": "List leave balances for all employees in Rippling"
|
||||
"name": "Get Department",
|
||||
"description": "Get a specific department by ID"
|
||||
},
|
||||
{
|
||||
"name": "Get Leave Balance",
|
||||
"description": "Get leave balance for a specific employee by role ID"
|
||||
"name": "Create Department",
|
||||
"description": "Create a new department"
|
||||
},
|
||||
{
|
||||
"name": "List Leave Types",
|
||||
"description": "List company leave types configured in Rippling"
|
||||
"name": "Update Department",
|
||||
"description": "Update an existing department"
|
||||
},
|
||||
{
|
||||
"name": "Create Group",
|
||||
"description": "Create a new group in Rippling"
|
||||
"name": "List Teams",
|
||||
"description": "List all teams"
|
||||
},
|
||||
{
|
||||
"name": "Update Group",
|
||||
"description": "Update an existing group in Rippling"
|
||||
"name": "Get Team",
|
||||
"description": "Get a specific team by ID"
|
||||
},
|
||||
{
|
||||
"name": "Push Candidate",
|
||||
"description": "Push a candidate to onboarding in Rippling"
|
||||
"name": "List Employment Types",
|
||||
"description": "List all employment types"
|
||||
},
|
||||
{
|
||||
"name": "Get Employment Type",
|
||||
"description": "Get a specific employment type by ID"
|
||||
},
|
||||
{
|
||||
"name": "List Titles",
|
||||
"description": "List all titles"
|
||||
},
|
||||
{
|
||||
"name": "Get Title",
|
||||
"description": "Get a specific title by ID"
|
||||
},
|
||||
{
|
||||
"name": "Create Title",
|
||||
"description": "Create a new title"
|
||||
},
|
||||
{
|
||||
"name": "Update Title",
|
||||
"description": "Update an existing title"
|
||||
},
|
||||
{
|
||||
"name": "Delete Title",
|
||||
"description": "Delete a title"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Fields",
|
||||
"description": "List all custom fields"
|
||||
},
|
||||
{
|
||||
"name": "List Job Functions",
|
||||
"description": "List all job functions"
|
||||
},
|
||||
{
|
||||
"name": "Get Job Function",
|
||||
"description": "Get a specific job function by ID"
|
||||
},
|
||||
{
|
||||
"name": "List Work Locations",
|
||||
"description": "List all work locations"
|
||||
},
|
||||
{
|
||||
"name": "Get Work Location",
|
||||
"description": "Get a specific work location by ID"
|
||||
},
|
||||
{
|
||||
"name": "Create Work Location",
|
||||
"description": "Create a new work location"
|
||||
},
|
||||
{
|
||||
"name": "Update Work Location",
|
||||
"description": "Update a work location"
|
||||
},
|
||||
{
|
||||
"name": "Delete Work Location",
|
||||
"description": "Delete a work location"
|
||||
},
|
||||
{
|
||||
"name": "List Business Partners",
|
||||
"description": "List all business partners"
|
||||
},
|
||||
{
|
||||
"name": "Get Business Partner",
|
||||
"description": "Get a specific business partner by ID"
|
||||
},
|
||||
{
|
||||
"name": "Create Business Partner",
|
||||
"description": "Create a new business partner"
|
||||
},
|
||||
{
|
||||
"name": "Delete Business Partner",
|
||||
"description": "Delete a business partner"
|
||||
},
|
||||
{
|
||||
"name": "List Business Partner Groups",
|
||||
"description": "List all business partner groups"
|
||||
},
|
||||
{
|
||||
"name": "Get Business Partner Group",
|
||||
"description": "Get a specific business partner group by ID"
|
||||
},
|
||||
{
|
||||
"name": "Create Business Partner Group",
|
||||
"description": "Create a new business partner group"
|
||||
},
|
||||
{
|
||||
"name": "Delete Business Partner Group",
|
||||
"description": "Delete a business partner group"
|
||||
},
|
||||
{
|
||||
"name": "List Supergroups",
|
||||
"description": "List all supergroups"
|
||||
},
|
||||
{
|
||||
"name": "Get Supergroup",
|
||||
"description": "Get a specific supergroup by ID"
|
||||
},
|
||||
{
|
||||
"name": "List Supergroup Members",
|
||||
"description": "List members of a supergroup"
|
||||
},
|
||||
{
|
||||
"name": "List Supergroup Inclusion Members",
|
||||
"description": "List inclusion members of a supergroup"
|
||||
},
|
||||
{
|
||||
"name": "List Supergroup Exclusion Members",
|
||||
"description": "List exclusion members of a supergroup"
|
||||
},
|
||||
{
|
||||
"name": "Update Supergroup Inclusion Members",
|
||||
"description": "Update inclusion members of a supergroup"
|
||||
},
|
||||
{
|
||||
"name": "Update Supergroup Exclusion Members",
|
||||
"description": "Update exclusion members of a supergroup"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Objects",
|
||||
"description": "List all custom objects"
|
||||
},
|
||||
{
|
||||
"name": "Get Custom Object",
|
||||
"description": "Get a custom object by API name"
|
||||
},
|
||||
{
|
||||
"name": "Create Custom Object",
|
||||
"description": "Create a new custom object"
|
||||
},
|
||||
{
|
||||
"name": "Update Custom Object",
|
||||
"description": "Update a custom object"
|
||||
},
|
||||
{
|
||||
"name": "Delete Custom Object",
|
||||
"description": "Delete a custom object"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Object Fields",
|
||||
"description": "List all fields for a custom object"
|
||||
},
|
||||
{
|
||||
"name": "Get Custom Object Field",
|
||||
"description": "Get a specific field of a custom object"
|
||||
},
|
||||
{
|
||||
"name": "Create Custom Object Field",
|
||||
"description": "Create a field on a custom object"
|
||||
},
|
||||
{
|
||||
"name": "Update Custom Object Field",
|
||||
"description": "Update a field on a custom object"
|
||||
},
|
||||
{
|
||||
"name": "Delete Custom Object Field",
|
||||
"description": "Delete a field from a custom object"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Object Records",
|
||||
"description": "List all records for a custom object"
|
||||
},
|
||||
{
|
||||
"name": "Get Custom Object Record",
|
||||
"description": "Get a specific custom object record"
|
||||
},
|
||||
{
|
||||
"name": "Get Custom Object Record by External ID",
|
||||
"description": "Get a custom object record by external ID"
|
||||
},
|
||||
{
|
||||
"name": "Query Custom Object Records",
|
||||
"description": "Query custom object records with filters"
|
||||
},
|
||||
{
|
||||
"name": "Create Custom Object Record",
|
||||
"description": "Create a custom object record"
|
||||
},
|
||||
{
|
||||
"name": "Update Custom Object Record",
|
||||
"description": "Update a custom object record"
|
||||
},
|
||||
{
|
||||
"name": "Delete Custom Object Record",
|
||||
"description": "Delete a custom object record"
|
||||
},
|
||||
{
|
||||
"name": "Bulk Create Custom Object Records",
|
||||
"description": "Bulk create custom object records"
|
||||
},
|
||||
{
|
||||
"name": "Bulk Update Custom Object Records",
|
||||
"description": "Bulk update custom object records"
|
||||
},
|
||||
{
|
||||
"name": "Bulk Delete Custom Object Records",
|
||||
"description": "Bulk delete custom object records"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Apps",
|
||||
"description": "List all custom apps"
|
||||
},
|
||||
{
|
||||
"name": "Get Custom App",
|
||||
"description": "Get a specific custom app"
|
||||
},
|
||||
{
|
||||
"name": "Create Custom App",
|
||||
"description": "Create a new custom app"
|
||||
},
|
||||
{
|
||||
"name": "Update Custom App",
|
||||
"description": "Update a custom app"
|
||||
},
|
||||
{
|
||||
"name": "Delete Custom App",
|
||||
"description": "Delete a custom app"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Pages",
|
||||
"description": "List all custom pages"
|
||||
},
|
||||
{
|
||||
"name": "Get Custom Page",
|
||||
"description": "Get a specific custom page"
|
||||
},
|
||||
{
|
||||
"name": "Create Custom Page",
|
||||
"description": "Create a new custom page"
|
||||
},
|
||||
{
|
||||
"name": "Update Custom Page",
|
||||
"description": "Update a custom page"
|
||||
},
|
||||
{
|
||||
"name": "Delete Custom Page",
|
||||
"description": "Delete a custom page"
|
||||
},
|
||||
{
|
||||
"name": "List Custom Settings",
|
||||
"description": "List all custom settings"
|
||||
},
|
||||
{
|
||||
"name": "Get Custom Setting",
|
||||
"description": "Get a specific custom setting"
|
||||
},
|
||||
{
|
||||
"name": "Create Custom Setting",
|
||||
"description": "Create a new custom setting"
|
||||
},
|
||||
{
|
||||
"name": "Update Custom Setting",
|
||||
"description": "Update a custom setting"
|
||||
},
|
||||
{
|
||||
"name": "Delete Custom Setting",
|
||||
"description": "Delete a custom setting"
|
||||
},
|
||||
{
|
||||
"name": "List Object Categories",
|
||||
"description": "List all object categories"
|
||||
},
|
||||
{
|
||||
"name": "Get Object Category",
|
||||
"description": "Get a specific object category"
|
||||
},
|
||||
{
|
||||
"name": "Create Object Category",
|
||||
"description": "Create a new object category"
|
||||
},
|
||||
{
|
||||
"name": "Update Object Category",
|
||||
"description": "Update an object category"
|
||||
},
|
||||
{
|
||||
"name": "Delete Object Category",
|
||||
"description": "Delete an object category"
|
||||
},
|
||||
{
|
||||
"name": "Get Report Run",
|
||||
"description": "Get a report run by ID"
|
||||
},
|
||||
{
|
||||
"name": "Trigger Report Run",
|
||||
"description": "Trigger a new report run"
|
||||
},
|
||||
{
|
||||
"name": "Create Draft Hires",
|
||||
"description": "Create bulk draft hires"
|
||||
}
|
||||
],
|
||||
"operationCount": 19,
|
||||
"operationCount": 86,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
@@ -9108,6 +9652,81 @@
|
||||
"integrationType": "hr",
|
||||
"tags": ["hiring"]
|
||||
},
|
||||
{
|
||||
"type": "rootly",
|
||||
"slug": "rootly",
|
||||
"name": "Rootly",
|
||||
"description": "Manage incidents, alerts, and on-call with Rootly",
|
||||
"longDescription": "Integrate Rootly incident management into workflows. Create and manage incidents, alerts, services, severities, and retrospectives.",
|
||||
"bgColor": "#6C72C8",
|
||||
"iconName": "RootlyIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/rootly",
|
||||
"operations": [
|
||||
{
|
||||
"name": "Create Incident",
|
||||
"description": "Create a new incident in Rootly with optional severity, services, and teams."
|
||||
},
|
||||
{
|
||||
"name": "Get Incident",
|
||||
"description": "Retrieve a single incident by ID from Rootly."
|
||||
},
|
||||
{
|
||||
"name": "Update Incident",
|
||||
"description": "Update an existing incident in Rootly (status, severity, summary, etc.)."
|
||||
},
|
||||
{
|
||||
"name": "List Incidents",
|
||||
"description": "List incidents from Rootly with optional filtering by status, severity, and more."
|
||||
},
|
||||
{
|
||||
"name": "Create Alert",
|
||||
"description": "Create a new alert in Rootly for on-call notification and routing."
|
||||
},
|
||||
{
|
||||
"name": "List Alerts",
|
||||
"description": "List alerts from Rootly with optional filtering by status, source, and services."
|
||||
},
|
||||
{
|
||||
"name": "Add Incident Event",
|
||||
"description": "Add a timeline event to an existing incident in Rootly."
|
||||
},
|
||||
{
|
||||
"name": "List Services",
|
||||
"description": "List services from Rootly with optional search filtering."
|
||||
},
|
||||
{
|
||||
"name": "List Severities",
|
||||
"description": "List severity levels configured in Rootly."
|
||||
},
|
||||
{
|
||||
"name": "List Teams",
|
||||
"description": "List teams (groups) configured in Rootly."
|
||||
},
|
||||
{
|
||||
"name": "List Environments",
|
||||
"description": "List environments configured in Rootly."
|
||||
},
|
||||
{
|
||||
"name": "List Incident Types",
|
||||
"description": "List incident types configured in Rootly."
|
||||
},
|
||||
{
|
||||
"name": "List Functionalities",
|
||||
"description": "List functionalities configured in Rootly."
|
||||
},
|
||||
{
|
||||
"name": "List Retrospectives",
|
||||
"description": "List incident retrospectives (post-mortems) from Rootly."
|
||||
}
|
||||
],
|
||||
"operationCount": 14,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "developer-tools",
|
||||
"tags": ["incident-management", "monitoring"]
|
||||
},
|
||||
{
|
||||
"type": "s3",
|
||||
"slug": "s3",
|
||||
@@ -10354,6 +10973,105 @@
|
||||
"integrationType": "databases",
|
||||
"tags": ["cloud", "data-warehouse", "vector-search"]
|
||||
},
|
||||
{
|
||||
"type": "tailscale",
|
||||
"slug": "tailscale",
|
||||
"name": "Tailscale",
|
||||
"description": "Manage devices and network settings in your Tailscale tailnet",
|
||||
"longDescription": "Interact with the Tailscale API to manage devices, DNS, ACLs, auth keys, users, and routes across your tailnet.",
|
||||
"bgColor": "#2E2D2D",
|
||||
"iconName": "TailscaleIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/tailscale",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List Devices",
|
||||
"description": "List all devices in the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Get Device",
|
||||
"description": "Get details of a specific device by ID"
|
||||
},
|
||||
{
|
||||
"name": "Delete Device",
|
||||
"description": "Remove a device from the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Authorize Device",
|
||||
"description": "Authorize or deauthorize a device on the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Set Device Tags",
|
||||
"description": "Set tags on a device in the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Get Device Routes",
|
||||
"description": "Get the subnet routes for a device"
|
||||
},
|
||||
{
|
||||
"name": "Set Device Routes",
|
||||
"description": "Set the enabled subnet routes for a device"
|
||||
},
|
||||
{
|
||||
"name": "Update Device Key",
|
||||
"description": "Enable or disable key expiry on a device"
|
||||
},
|
||||
{
|
||||
"name": "List DNS Nameservers",
|
||||
"description": "Get the DNS nameservers configured for the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Set DNS Nameservers",
|
||||
"description": "Set the DNS nameservers for the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Get DNS Preferences",
|
||||
"description": "Get the DNS preferences for the tailnet including MagicDNS status"
|
||||
},
|
||||
{
|
||||
"name": "Set DNS Preferences",
|
||||
"description": "Set DNS preferences for the tailnet (enable/disable MagicDNS)"
|
||||
},
|
||||
{
|
||||
"name": "Get DNS Search Paths",
|
||||
"description": "Get the DNS search paths configured for the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Set DNS Search Paths",
|
||||
"description": "Set the DNS search paths for the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "List Users",
|
||||
"description": "List all users in the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Create Auth Key",
|
||||
"description": "Create a new auth key for the tailnet to pre-authorize devices"
|
||||
},
|
||||
{
|
||||
"name": "List Auth Keys",
|
||||
"description": "List all auth keys in the tailnet"
|
||||
},
|
||||
{
|
||||
"name": "Get Auth Key",
|
||||
"description": "Get details of a specific auth key"
|
||||
},
|
||||
{
|
||||
"name": "Delete Auth Key",
|
||||
"description": "Revoke and delete an auth key"
|
||||
},
|
||||
{
|
||||
"name": "Get ACL",
|
||||
"description": "Get the current ACL policy for the tailnet"
|
||||
}
|
||||
],
|
||||
"operationCount": 20,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "security",
|
||||
"tags": ["monitoring"]
|
||||
},
|
||||
{
|
||||
"type": "tavily",
|
||||
"slug": "tavily",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
|
||||
import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
getModelBySlug,
|
||||
getProviderBySlug,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
export const runtime = 'edge'
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string; model: string }>
|
||||
}) {
|
||||
const { provider: providerSlug, model: modelSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
const model = getModelBySlug(providerSlug, modelSlug)
|
||||
|
||||
if (!provider || !model) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return createModelsOgImage({
|
||||
eyebrow: `${provider.name} model`,
|
||||
title: model.displayName,
|
||||
subtitle: `${provider.name} pricing, context window, and feature support generated from Sim's model registry.`,
|
||||
pills: [
|
||||
`Input ${formatPrice(model.pricing.input)}/1M`,
|
||||
`Output ${formatPrice(model.pricing.output)}/1M`,
|
||||
model.contextWindow ? `${formatTokenCount(model.contextWindow)} context` : 'Unknown context',
|
||||
model.capabilityTags[0] ?? 'Capabilities tracked',
|
||||
],
|
||||
domainLabel: `sim.ai${model.href}`,
|
||||
})
|
||||
}
|
||||
390
apps/sim/app/(landing)/models/[provider]/[model]/page.tsx
Normal file
390
apps/sim/app/(landing)/models/[provider]/[model]/page.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
CapabilityTags,
|
||||
DetailItem,
|
||||
ModelCard,
|
||||
ProviderIcon,
|
||||
StatCard,
|
||||
} from '@/app/(landing)/models/components/model-primitives'
|
||||
import {
|
||||
ALL_CATALOG_MODELS,
|
||||
buildModelCapabilityFacts,
|
||||
buildModelFaqs,
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
formatUpdatedAt,
|
||||
getModelBySlug,
|
||||
getPricingBounds,
|
||||
getProviderBySlug,
|
||||
getRelatedModels,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return ALL_CATALOG_MODELS.map((model) => ({
|
||||
provider: model.providerSlug,
|
||||
model: model.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string; model: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { provider: providerSlug, model: modelSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
const model = getModelBySlug(providerSlug, modelSlug)
|
||||
|
||||
if (!provider || !model) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${model.displayName} Pricing, Context Window, and Features`,
|
||||
description: `${model.displayName} by ${provider.name}: pricing, cached input cost, output cost, context window, and capability support. Explore the full generated model page on Sim.`,
|
||||
keywords: [
|
||||
model.displayName,
|
||||
`${model.displayName} pricing`,
|
||||
`${model.displayName} context window`,
|
||||
`${model.displayName} features`,
|
||||
`${provider.name} ${model.displayName}`,
|
||||
`${provider.name} model pricing`,
|
||||
...model.capabilityTags,
|
||||
],
|
||||
openGraph: {
|
||||
title: `${model.displayName} Pricing, Context Window, and Features | Sim`,
|
||||
description: `${model.displayName} by ${provider.name}: pricing, context window, and model capability details.`,
|
||||
url: `${baseUrl}${model.href}`,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: `${baseUrl}${model.href}/opengraph-image`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${model.displayName} on Sim`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${model.displayName} | Sim`,
|
||||
description: model.summary,
|
||||
images: [
|
||||
{ url: `${baseUrl}${model.href}/opengraph-image`, alt: `${model.displayName} on Sim` },
|
||||
],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}${model.href}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ModelPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string; model: string }>
|
||||
}) {
|
||||
const { provider: providerSlug, model: modelSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
const model = getModelBySlug(providerSlug, modelSlug)
|
||||
|
||||
if (!provider || !model) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const faqs = buildModelFaqs(provider, model)
|
||||
const capabilityFacts = buildModelCapabilityFacts(model)
|
||||
const pricingBounds = getPricingBounds(model.pricing)
|
||||
const relatedModels = getRelatedModels(model, 6)
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
|
||||
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 4,
|
||||
name: model.displayName,
|
||||
item: `${baseUrl}${model.href}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const productJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: model.displayName,
|
||||
brand: provider.name,
|
||||
category: 'AI language model',
|
||||
description: model.summary,
|
||||
sku: model.id,
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
priceCurrency: 'USD',
|
||||
lowPrice: pricingBounds.lowPrice.toString(),
|
||||
highPrice: pricingBounds.highPrice.toString(),
|
||||
},
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Models', href: '/models' },
|
||||
{ label: provider.name, href: provider.href },
|
||||
{ label: model.displayName },
|
||||
]}
|
||||
/>
|
||||
|
||||
<section aria-labelledby='model-heading' className='mb-14'>
|
||||
<div className='mb-6 flex items-start gap-4'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-16 w-16 rounded-3xl'
|
||||
iconClassName='h-8 w-8'
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
|
||||
{provider.name} model
|
||||
</p>
|
||||
<h1
|
||||
id='model-heading'
|
||||
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
|
||||
>
|
||||
{model.displayName}
|
||||
</h1>
|
||||
<p className='mt-2 break-all text-[13px] text-[var(--landing-text-muted)]'>
|
||||
Model ID: {model.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{model.summary} {model.bestFor}
|
||||
</p>
|
||||
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Explore {provider.name} models
|
||||
</Link>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Build with this model
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label='Model stats'
|
||||
className='mb-16 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'
|
||||
>
|
||||
<StatCard label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
|
||||
<StatCard
|
||||
label='Cached input'
|
||||
value={
|
||||
model.pricing.cachedInput !== undefined
|
||||
? `${formatPrice(model.pricing.cachedInput)}/1M`
|
||||
: 'N/A'
|
||||
}
|
||||
compact
|
||||
/>
|
||||
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
|
||||
<StatCard
|
||||
label='Context window'
|
||||
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className='grid grid-cols-1 gap-16 lg:grid-cols-[1fr_320px]'>
|
||||
<div className='min-w-0 space-y-16'>
|
||||
<section aria-labelledby='pricing-heading'>
|
||||
<h2
|
||||
id='pricing-heading'
|
||||
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Pricing and limits
|
||||
</h2>
|
||||
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Pricing below is generated directly from the provider registry in Sim. All amounts
|
||||
are listed per one million tokens.
|
||||
</p>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<DetailItem label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
|
||||
<DetailItem
|
||||
label='Cached input'
|
||||
value={
|
||||
model.pricing.cachedInput !== undefined
|
||||
? `${formatPrice(model.pricing.cachedInput)}/1M`
|
||||
: 'N/A'
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
label='Output price'
|
||||
value={`${formatPrice(model.pricing.output)}/1M`}
|
||||
/>
|
||||
<DetailItem label='Updated' value={formatUpdatedAt(model.pricing.updatedAt)} />
|
||||
<DetailItem
|
||||
label='Context window'
|
||||
value={
|
||||
model.contextWindow
|
||||
? `${formatTokenCount(model.contextWindow)} tokens`
|
||||
: 'Unknown'
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
label='Max output'
|
||||
value={
|
||||
model.capabilities.maxOutputTokens
|
||||
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
|
||||
: 'Standard defaults'
|
||||
}
|
||||
/>
|
||||
<DetailItem label='Provider' value={provider.name} />
|
||||
<DetailItem label='Best for' value={model.bestFor} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby='capabilities-heading'>
|
||||
<h2
|
||||
id='capabilities-heading'
|
||||
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Capabilities
|
||||
</h2>
|
||||
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
These capability flags are generated from the provider and model definitions tracked
|
||||
in Sim.
|
||||
</p>
|
||||
<CapabilityTags tags={model.capabilityTags} />
|
||||
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{capabilityFacts.map((item) => (
|
||||
<DetailItem key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedModels.length > 0 && (
|
||||
<section aria-labelledby='related-models-heading'>
|
||||
<h2
|
||||
id='related-models-heading'
|
||||
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Related {provider.name} models
|
||||
</h2>
|
||||
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Browse comparable models from the same provider to compare pricing, context
|
||||
window, and capability coverage.
|
||||
</p>
|
||||
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
|
||||
{relatedModels.map((entry) => (
|
||||
<ModelCard key={entry.id} provider={provider} model={entry} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-labelledby='model-faq-heading'
|
||||
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
|
||||
>
|
||||
<h2
|
||||
id='model-faq-heading'
|
||||
className='font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className='mt-3'>
|
||||
<LandingFAQ faqs={faqs} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className='space-y-5' aria-label='Model details'>
|
||||
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
|
||||
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
|
||||
Quick details
|
||||
</h2>
|
||||
<div className='space-y-3'>
|
||||
<DetailItem label='Display name' value={model.displayName} />
|
||||
<DetailItem label='Provider' value={provider.name} />
|
||||
<DetailItem
|
||||
label='Context tracked'
|
||||
value={model.contextWindow ? 'Yes' : 'Partial'}
|
||||
/>
|
||||
<DetailItem
|
||||
label='Pricing updated'
|
||||
value={formatUpdatedAt(model.pricing.updatedAt)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
|
||||
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
|
||||
Browse more
|
||||
</h2>
|
||||
<div className='space-y-2'>
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
|
||||
>
|
||||
All {provider.name} models
|
||||
</Link>
|
||||
<Link
|
||||
href='/models'
|
||||
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
|
||||
>
|
||||
Full models directory
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
43
apps/sim/app/(landing)/models/[provider]/opengraph-image.tsx
Normal file
43
apps/sim/app/(landing)/models/[provider]/opengraph-image.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
|
||||
import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
getCheapestProviderModel,
|
||||
getLargestContextProviderModel,
|
||||
getProviderBySlug,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
export const runtime = 'edge'
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ provider: string }> }) {
|
||||
const { provider: providerSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
|
||||
if (!provider || provider.models.length === 0) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const cheapestModel = getCheapestProviderModel(provider)
|
||||
const largestContextModel = getLargestContextProviderModel(provider)
|
||||
|
||||
return createModelsOgImage({
|
||||
eyebrow: `${provider.name} on Sim`,
|
||||
title: `${provider.name} models`,
|
||||
subtitle: `Browse ${provider.modelCount} tracked ${provider.name} models with pricing, context windows, default model selection, and model capability coverage.`,
|
||||
pills: [
|
||||
`${provider.modelCount} tracked`,
|
||||
provider.defaultModelDisplayName || 'Dynamic default',
|
||||
cheapestModel ? `From ${formatPrice(cheapestModel.pricing.input)}/1M` : 'Pricing tracked',
|
||||
largestContextModel?.contextWindow
|
||||
? `${formatTokenCount(largestContextModel.contextWindow)} context`
|
||||
: 'Context tracked',
|
||||
],
|
||||
domainLabel: `sim.ai${provider.href}`,
|
||||
})
|
||||
}
|
||||
294
apps/sim/app/(landing)/models/[provider]/page.tsx
Normal file
294
apps/sim/app/(landing)/models/[provider]/page.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
CapabilityTags,
|
||||
ModelCard,
|
||||
ProviderCard,
|
||||
ProviderIcon,
|
||||
StatCard,
|
||||
} from '@/app/(landing)/models/components/model-primitives'
|
||||
import {
|
||||
buildProviderFaqs,
|
||||
getProviderBySlug,
|
||||
getProviderCapabilitySummary,
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
TOP_MODEL_PROVIDERS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
provider: provider.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { provider: providerSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
|
||||
if (!provider || provider.models.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const providerFaqs = buildProviderFaqs(provider)
|
||||
|
||||
return {
|
||||
title: `${provider.name} Models`,
|
||||
description: `Browse ${provider.modelCount} ${provider.name} models tracked in Sim. Compare pricing, context windows, default model selection, and capabilities for ${provider.name}'s AI model lineup.`,
|
||||
keywords: [
|
||||
`${provider.name} models`,
|
||||
`${provider.name} pricing`,
|
||||
`${provider.name} context window`,
|
||||
`${provider.name} model list`,
|
||||
`${provider.name} AI models`,
|
||||
...provider.models.slice(0, 6).map((model) => model.displayName),
|
||||
],
|
||||
openGraph: {
|
||||
title: `${provider.name} Models | Sim`,
|
||||
description: `Explore ${provider.modelCount} ${provider.name} models with pricing and capability details.`,
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: `${baseUrl}${provider.href}/opengraph-image`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${provider.name} Models on Sim`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${provider.name} Models | Sim`,
|
||||
description: providerFaqs[0]?.answer ?? provider.summary,
|
||||
images: [
|
||||
{
|
||||
url: `${baseUrl}${provider.href}/opengraph-image`,
|
||||
alt: `${provider.name} Models on Sim`,
|
||||
},
|
||||
],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}${provider.href}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProviderModelsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}) {
|
||||
const { provider: providerSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
|
||||
if (!provider || provider.models.length === 0) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const faqs = buildProviderFaqs(provider)
|
||||
const capabilitySummary = getProviderCapabilitySummary(provider)
|
||||
const relatedProviders = MODEL_PROVIDERS_WITH_CATALOGS.filter(
|
||||
(entry) => entry.id !== provider.id && TOP_MODEL_PROVIDERS.includes(entry.name)
|
||||
).slice(0, 4)
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
|
||||
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `${provider.name} Models`,
|
||||
description: `List of ${provider.modelCount} ${provider.name} models tracked in Sim.`,
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
numberOfItems: provider.modelCount,
|
||||
itemListElement: provider.models.map((model, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `${baseUrl}${model.href}`,
|
||||
name: model.displayName,
|
||||
})),
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Models', href: '/models' },
|
||||
{ label: provider.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<section aria-labelledby='provider-heading' className='mb-14'>
|
||||
<div className='mb-6 flex items-center gap-4'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-16 w-16 rounded-3xl'
|
||||
iconClassName='h-8 w-8'
|
||||
/>
|
||||
<div>
|
||||
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
|
||||
Provider
|
||||
</p>
|
||||
<h1
|
||||
id='provider-heading'
|
||||
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
|
||||
>
|
||||
{provider.name} models
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{provider.summary} Browse every {provider.name} model page generated from Sim's
|
||||
provider registry with human-readable names, pricing, context windows, and capability
|
||||
metadata.
|
||||
</p>
|
||||
|
||||
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<StatCard label='Models tracked' value={provider.modelCount.toString()} />
|
||||
<StatCard
|
||||
label='Default model'
|
||||
value={provider.defaultModelDisplayName || 'Dynamic'}
|
||||
compact
|
||||
/>
|
||||
<StatCard
|
||||
label='Metadata coverage'
|
||||
value={provider.contextInformationAvailable ? 'Tracked' : 'Partial'}
|
||||
compact
|
||||
/>
|
||||
<StatCard
|
||||
label='Featured models'
|
||||
value={provider.featuredModels.length.toString()}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<CapabilityTags tags={provider.providerCapabilityTags} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby='provider-models-heading' className='mb-16'>
|
||||
<h2
|
||||
id='provider-models-heading'
|
||||
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
All {provider.name} models
|
||||
</h2>
|
||||
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Every model below links to a dedicated SEO page with exact pricing, context window,
|
||||
capability support, and related model recommendations.
|
||||
</p>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
|
||||
{provider.models.map((model) => (
|
||||
<ModelCard key={model.id} provider={provider} model={model} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby='lineup-snapshot-heading'
|
||||
className='mb-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
|
||||
>
|
||||
<h2
|
||||
id='lineup-snapshot-heading'
|
||||
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Lineup snapshot
|
||||
</h2>
|
||||
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
A quick view of the strongest differentiators in the {provider.name} model lineup based
|
||||
on the metadata currently tracked in Sim.
|
||||
</p>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{capabilitySummary.map((item) => (
|
||||
<StatCard key={item.label} label={item.label} value={item.value} compact />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedProviders.length > 0 && (
|
||||
<section aria-labelledby='related-providers-heading' className='mb-16'>
|
||||
<h2
|
||||
id='related-providers-heading'
|
||||
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Compare with other providers
|
||||
</h2>
|
||||
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Explore similar provider hubs to compare model lineups, pricing surfaces, and
|
||||
long-context coverage across the broader AI ecosystem.
|
||||
</p>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{relatedProviders.map((entry) => (
|
||||
<ProviderCard key={entry.id} provider={entry} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-labelledby='provider-faq-heading'
|
||||
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
|
||||
>
|
||||
<h2
|
||||
id='provider-faq-heading'
|
||||
className='font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className='mt-3'>
|
||||
<LandingFAQ faqs={faqs} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
291
apps/sim/app/(landing)/models/components/model-directory.tsx
Normal file
291
apps/sim/app/(landing)/models/components/model-directory.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Input } from '@/components/emcn'
|
||||
import { SearchIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
CapabilityTags,
|
||||
DetailItem,
|
||||
ModelCard,
|
||||
ProviderIcon,
|
||||
StatCard,
|
||||
} from '@/app/(landing)/models/components/model-primitives'
|
||||
import {
|
||||
type CatalogProvider,
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
|
||||
TOTAL_MODELS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
export function ModelDirectory() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
count: provider.modelCount,
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
|
||||
const { filteredProviders, filteredDynamicProviders, visibleModelCount } = useMemo(() => {
|
||||
const filteredProviders = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => {
|
||||
const providerMatchesSearch =
|
||||
normalizedQuery.length > 0 && provider.searchText.includes(normalizedQuery)
|
||||
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
|
||||
|
||||
if (!providerMatchesFilter) {
|
||||
return null
|
||||
}
|
||||
|
||||
const models =
|
||||
normalizedQuery.length === 0
|
||||
? provider.models
|
||||
: provider.models.filter(
|
||||
(model) =>
|
||||
model.searchText.includes(normalizedQuery) ||
|
||||
(providerMatchesSearch && normalizedQuery.length > 0)
|
||||
)
|
||||
|
||||
if (!providerMatchesSearch && models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
models: providerMatchesSearch && normalizedQuery.length > 0 ? provider.models : models,
|
||||
}
|
||||
}).filter((provider): provider is CatalogProvider => provider !== null)
|
||||
|
||||
const filteredDynamicProviders = MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS.filter((provider) => {
|
||||
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
|
||||
if (!providerMatchesFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true
|
||||
}
|
||||
|
||||
return provider.searchText.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
const visibleModelCount = filteredProviders.reduce(
|
||||
(count, provider) => count + provider.models.length,
|
||||
0
|
||||
)
|
||||
|
||||
return {
|
||||
filteredProviders,
|
||||
filteredDynamicProviders,
|
||||
visibleModelCount,
|
||||
}
|
||||
}, [activeProviderId, normalizedQuery])
|
||||
|
||||
const hasResults = filteredProviders.length > 0 || filteredDynamicProviders.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='relative max-w-[560px] flex-1'>
|
||||
<SearchIcon
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[var(--landing-text-muted)]'
|
||||
/>
|
||||
<Input
|
||||
type='search'
|
||||
placeholder='Search models, providers, capabilities, or pricing details'
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className='h-11 border-[var(--landing-border)] bg-[var(--landing-bg-card)] pl-10 text-[var(--landing-text)] placeholder:text-[var(--landing-text-muted)]'
|
||||
aria-label='Search AI models'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Showing {visibleModelCount.toLocaleString('en-US')} of{' '}
|
||||
{TOTAL_MODELS.toLocaleString('en-US')} models
|
||||
{activeProviderId ? ' in one provider' : ''}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mb-10 flex flex-wrap gap-2'>
|
||||
<FilterButton
|
||||
isActive={activeProviderId === null}
|
||||
onClick={() => setActiveProviderId(null)}
|
||||
label={`All providers (${MODEL_PROVIDERS_WITH_CATALOGS.length})`}
|
||||
/>
|
||||
{providerOptions.map((provider) => (
|
||||
<FilterButton
|
||||
key={provider.id}
|
||||
isActive={activeProviderId === provider.id}
|
||||
onClick={() =>
|
||||
setActiveProviderId(activeProviderId === provider.id ? null : provider.id)
|
||||
}
|
||||
label={`${provider.name} (${provider.count})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!hasResults ? (
|
||||
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-6 py-12 text-center'>
|
||||
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>No matches found</h3>
|
||||
<p className='mt-2 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Try a provider name like OpenAI or Anthropic, or search for capabilities like
|
||||
structured outputs, reasoning, or deep research.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-10'>
|
||||
{filteredProviders.map((provider) => (
|
||||
<section
|
||||
key={provider.id}
|
||||
aria-labelledby={`${provider.id}-heading`}
|
||||
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
|
||||
>
|
||||
<div className='mb-6 flex flex-col gap-5 border-[var(--landing-border)] border-b pb-6 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='min-w-0'>
|
||||
<div className='mb-3 flex items-center gap-3'>
|
||||
<ProviderIcon provider={provider} />
|
||||
<div>
|
||||
<p className='text-[12px] text-[var(--landing-text-muted)]'>Provider</p>
|
||||
<h2
|
||||
id={`${provider.id}-heading`}
|
||||
className='font-[500] text-[24px] text-[var(--landing-text)]'
|
||||
>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='max-w-[720px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{provider.description}
|
||||
</p>
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='mt-3 inline-flex text-[#555] text-[13px] transition-colors hover:text-[var(--landing-text-muted)]'
|
||||
>
|
||||
View provider page →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-3'>
|
||||
<StatCard label='Models' value={provider.models.length.toString()} />
|
||||
<StatCard
|
||||
label='Default'
|
||||
value={provider.defaultModelDisplayName || 'Dynamic'}
|
||||
compact
|
||||
/>
|
||||
<StatCard
|
||||
label='Context info'
|
||||
value={provider.contextInformationAvailable ? 'Tracked' : 'Limited'}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-6'>
|
||||
<CapabilityTags tags={provider.providerCapabilityTags} />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
|
||||
{provider.models.map((model) => (
|
||||
<ModelCard key={model.id} provider={provider} model={model} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{filteredDynamicProviders.length > 0 && (
|
||||
<section
|
||||
aria-labelledby='dynamic-catalogs-heading'
|
||||
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
|
||||
>
|
||||
<div className='mb-6'>
|
||||
<h2
|
||||
id='dynamic-catalogs-heading'
|
||||
className='font-[500] text-[24px] text-[var(--landing-text)]'
|
||||
>
|
||||
Dynamic model catalogs
|
||||
</h2>
|
||||
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
These providers are supported by Sim, but their model lists are loaded dynamically
|
||||
at runtime rather than hard-coded into the public catalog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{filteredDynamicProviders.map((provider) => (
|
||||
<article
|
||||
key={provider.id}
|
||||
className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] p-5'
|
||||
>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<ProviderIcon provider={provider} />
|
||||
<div className='min-w-0'>
|
||||
<h3 className='font-[500] text-[16px] text-[var(--landing-text)]'>
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p className='text-[12px] text-[var(--landing-text-muted)]'>
|
||||
{provider.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{provider.description}
|
||||
</p>
|
||||
|
||||
<div className='mt-4 space-y-3 text-[13px]'>
|
||||
<DetailItem
|
||||
label='Default'
|
||||
value={provider.defaultModelDisplayName || 'Selected at runtime'}
|
||||
/>
|
||||
<DetailItem label='Catalog source' value='Loaded dynamically inside Sim' />
|
||||
</div>
|
||||
|
||||
<div className='mt-4'>
|
||||
<CapabilityTags tags={provider.providerCapabilityTags} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterButton({
|
||||
isActive,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-[12px] transition-colors',
|
||||
isActive
|
||||
? 'border-[#555] bg-[#333] text-[var(--landing-text)]'
|
||||
: 'border-[var(--landing-border)] bg-transparent text-[var(--landing-text-muted)] hover:border-[var(--landing-border-strong)] hover:text-[var(--landing-text)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
214
apps/sim/app/(landing)/models/components/model-primitives.tsx
Normal file
214
apps/sim/app/(landing)/models/components/model-primitives.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import {
|
||||
type CatalogModel,
|
||||
type CatalogProvider,
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
formatUpdatedAt,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
export function Breadcrumbs({ items }: { items: Array<{ label: string; href?: string }> }) {
|
||||
return (
|
||||
<nav
|
||||
aria-label='Breadcrumb'
|
||||
className='mb-10 flex flex-wrap items-center gap-2 text-[#555] text-[13px]'
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<span key={`${item.label}-${index}`} className='inline-flex items-center gap-2'>
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className='transition-colors hover:text-[var(--landing-text-muted)]'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className='text-[var(--landing-text-muted)]'>{item.label}</span>
|
||||
)}
|
||||
{index < items.length - 1 ? <span aria-hidden='true'>/</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderIcon({
|
||||
provider,
|
||||
className = 'h-12 w-12 rounded-2xl',
|
||||
iconClassName = 'h-6 w-6',
|
||||
}: {
|
||||
provider: Pick<CatalogProvider, 'icon' | 'name'>
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
}) {
|
||||
const Icon = provider.icon
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center justify-center border border-[var(--landing-border)] bg-[var(--landing-bg)] ${className}`}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={iconClassName} />
|
||||
) : (
|
||||
<span className='font-[500] text-[14px] text-[var(--landing-text)]'>
|
||||
{provider.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
compact = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] px-4 py-3'>
|
||||
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-1 font-[500] text-[var(--landing-text)] ${
|
||||
compact ? 'break-all text-[12px] leading-snug' : 'text-[18px]'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className='rounded-xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-3 py-2'>
|
||||
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
|
||||
{label}
|
||||
</p>
|
||||
<p className='mt-1 break-words font-[500] text-[12px] text-[var(--landing-text)] leading-snug'>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CapabilityTags({ tags }: { tags: string[] }) {
|
||||
if (tags.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
className='border-[var(--landing-border)] bg-transparent px-2 py-1 text-[11px] text-[var(--landing-text-muted)]'
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderCard({ provider }: { provider: CatalogProvider }) {
|
||||
return (
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<ProviderIcon provider={provider} />
|
||||
<div className='min-w-0'>
|
||||
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>{provider.name}</h3>
|
||||
<p className='text-[12px] text-[var(--landing-text-muted)]'>
|
||||
{provider.modelCount} models tracked
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='mb-4 flex-1 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{provider.description}
|
||||
</p>
|
||||
|
||||
<div className='mb-4 grid grid-cols-2 gap-3'>
|
||||
<DetailItem label='Default' value={provider.defaultModelDisplayName || 'Dynamic'} />
|
||||
<DetailItem
|
||||
label='Catalog'
|
||||
value={provider.contextInformationAvailable ? 'Tracked metadata' : 'Partial metadata'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CapabilityTags tags={provider.providerCapabilityTags.slice(0, 4)} />
|
||||
|
||||
<p className='mt-4 text-[#555] text-[13px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
|
||||
Explore provider →
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelCard({
|
||||
provider,
|
||||
model,
|
||||
showProvider = false,
|
||||
}: {
|
||||
provider: CatalogProvider
|
||||
model: CatalogModel
|
||||
showProvider?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={model.href}
|
||||
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
<div className='mb-4 flex items-start gap-3'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='h-10 w-10 rounded-xl'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
{showProvider ? (
|
||||
<p className='mb-1 text-[12px] text-[var(--landing-text-muted)]'>{provider.name}</p>
|
||||
) : null}
|
||||
<h3 className='break-all font-[500] text-[16px] text-[var(--landing-text)] leading-snug'>
|
||||
{model.displayName}
|
||||
</h3>
|
||||
<p className='mt-1 break-all text-[12px] text-[var(--landing-text-muted)]'>{model.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-[12px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{model.summary}
|
||||
</p>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
|
||||
{`Input ${formatPrice(model.pricing.input)}/1M`}
|
||||
</Badge>
|
||||
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
|
||||
{`Output ${formatPrice(model.pricing.output)}/1M`}
|
||||
</Badge>
|
||||
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
|
||||
{model.contextWindow
|
||||
? `${formatTokenCount(model.contextWindow)} context`
|
||||
: 'Unknown context'}
|
||||
</Badge>
|
||||
{model.capabilityTags[0] ? (
|
||||
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
|
||||
{model.capabilityTags[0]}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className='ml-auto text-[#555] text-[12px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
|
||||
{`Updated ${formatUpdatedAt(model.pricing.updatedAt)} →`}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
42
apps/sim/app/(landing)/models/layout.tsx
Normal file
42
apps/sim/app/(landing)/models/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function ModelsLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
const url = getBaseUrl()
|
||||
const orgJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
url,
|
||||
logo: `${url}/logo/primary/small.png`,
|
||||
sameAs: ['https://x.com/simdotai'],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Sim',
|
||||
url,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='dark flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<header>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
<main className='relative flex-1'>{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
apps/sim/app/(landing)/models/og-utils.tsx
Normal file
205
apps/sim/app/(landing)/models/og-utils.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
const TITLE_FONT_SIZE = {
|
||||
large: 64,
|
||||
medium: 56,
|
||||
small: 48,
|
||||
} as const
|
||||
|
||||
function getTitleFontSize(title: string): number {
|
||||
if (title.length > 42) return TITLE_FONT_SIZE.small
|
||||
if (title.length > 26) return TITLE_FONT_SIZE.medium
|
||||
return TITLE_FONT_SIZE.large
|
||||
}
|
||||
|
||||
async function loadGoogleFont(
|
||||
font: string,
|
||||
weights: string,
|
||||
text: string
|
||||
): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}`
|
||||
const css = await (await fetch(url)).text()
|
||||
const resource = css.match(/src: url\(([^)]+)\) format\('(opentype|truetype|woff2?)'\)/)
|
||||
|
||||
if (resource) {
|
||||
const response = await fetch(resource[1])
|
||||
if (response.status === 200) {
|
||||
return await response.arrayBuffer()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function SimLogoFull() {
|
||||
return (
|
||||
<svg height='28' viewBox='720 440 1020 320' fill='none'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
<path
|
||||
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
<path
|
||||
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
|
||||
fill='#fafafa'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelsOgImageProps {
|
||||
eyebrow: string
|
||||
title: string
|
||||
subtitle: string
|
||||
pills?: string[]
|
||||
domainLabel?: string
|
||||
}
|
||||
|
||||
export async function createModelsOgImage({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
pills = [],
|
||||
domainLabel = 'sim.ai/models',
|
||||
}: ModelsOgImageProps) {
|
||||
const text = `${eyebrow}${title}${subtitle}${pills.join('')}${domainLabel}`
|
||||
const [regularFontData, mediumFontData] = await Promise.all([
|
||||
loadGoogleFont('Geist', '400', text),
|
||||
loadGoogleFont('Geist', '500', text),
|
||||
])
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '56px 64px',
|
||||
background: '#121212',
|
||||
fontFamily: 'Geist',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 500,
|
||||
color: '#71717a',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{eyebrow}
|
||||
</span>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: getTitleFontSize(title),
|
||||
fontWeight: 500,
|
||||
color: '#fafafa',
|
||||
lineHeight: 1.08,
|
||||
letterSpacing: '-0.03em',
|
||||
maxWidth: '1000px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 400,
|
||||
color: '#a1a1aa',
|
||||
lineHeight: 1.35,
|
||||
maxWidth: '980px',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
|
||||
{pills.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 4 }}>
|
||||
{pills.slice(0, 4).map((pill) => (
|
||||
<div
|
||||
key={pill}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 9999,
|
||||
border: '1px solid #2f2f2f',
|
||||
background: '#1b1b1b',
|
||||
padding: '10px 16px',
|
||||
color: '#d4d4d8',
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{pill}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<SimLogoFull />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 400,
|
||||
color: '#71717a',
|
||||
}}
|
||||
>
|
||||
{domainLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
...(regularFontData
|
||||
? [
|
||||
{
|
||||
name: 'Geist',
|
||||
data: regularFontData,
|
||||
style: 'normal' as const,
|
||||
weight: 400 as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(mediumFontData
|
||||
? [
|
||||
{
|
||||
name: 'Geist',
|
||||
data: mediumFontData,
|
||||
style: 'normal' as const,
|
||||
weight: 500 as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
29
apps/sim/app/(landing)/models/opengraph-image.tsx
Normal file
29
apps/sim/app/(landing)/models/opengraph-image.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
|
||||
import {
|
||||
formatTokenCount,
|
||||
MAX_CONTEXT_WINDOW,
|
||||
TOTAL_MODEL_PROVIDERS,
|
||||
TOTAL_MODELS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
export const runtime = 'edge'
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export default async function Image() {
|
||||
return createModelsOgImage({
|
||||
eyebrow: 'Sim model directory',
|
||||
title: 'AI Models Directory',
|
||||
subtitle:
|
||||
'Browse tracked AI models with pricing, context windows, and workflow-ready capability details.',
|
||||
pills: [
|
||||
`${TOTAL_MODELS} models`,
|
||||
`${TOTAL_MODEL_PROVIDERS} providers`,
|
||||
`${formatTokenCount(MAX_CONTEXT_WINDOW)} max context`,
|
||||
],
|
||||
domainLabel: 'sim.ai/models',
|
||||
})
|
||||
}
|
||||
293
apps/sim/app/(landing)/models/page.tsx
Normal file
293
apps/sim/app/(landing)/models/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import { ModelDirectory } from '@/app/(landing)/models/components/model-directory'
|
||||
import { ModelCard, ProviderCard } from '@/app/(landing)/models/components/model-primitives'
|
||||
import {
|
||||
getPricingBounds,
|
||||
MODEL_CATALOG_PROVIDERS,
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
TOP_MODEL_PROVIDERS,
|
||||
TOTAL_MODEL_PROVIDERS,
|
||||
TOTAL_MODELS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'What is the Sim AI models directory?',
|
||||
answer:
|
||||
'The Sim AI models directory is a public catalog of the language models and providers tracked inside Sim. It shows provider coverage, model IDs, pricing per one million tokens, context windows, and supported capabilities such as reasoning controls, structured outputs, and deep research.',
|
||||
},
|
||||
{
|
||||
question: 'Can I compare models from multiple providers in one place?',
|
||||
answer:
|
||||
'Yes. This page organizes every tracked model by provider and lets you search across providers, model names, and capabilities. You can quickly compare OpenAI, Anthropic, Google, xAI, Mistral, Groq, Cerebras, Fireworks, Bedrock, and more from a single directory.',
|
||||
},
|
||||
{
|
||||
question: 'Are these model prices shown per million tokens?',
|
||||
answer:
|
||||
'Yes. Input, cached input, and output prices on this page are shown per one million tokens based on the provider metadata tracked in Sim.',
|
||||
},
|
||||
{
|
||||
question: 'Does Sim support providers with dynamic model catalogs too?',
|
||||
answer:
|
||||
'Yes. Some providers such as OpenRouter, Fireworks, Ollama, and vLLM load their model lists dynamically at runtime. Those providers are still shown here even when their full public model list is not hard-coded into the catalog.',
|
||||
},
|
||||
]
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI Models Directory',
|
||||
description: `Browse ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers. Compare pricing, context windows, and capabilities for OpenAI, Anthropic, Google, xAI, Mistral, Bedrock, Groq, and more.`,
|
||||
keywords: [
|
||||
'AI models directory',
|
||||
'LLM model list',
|
||||
'model pricing',
|
||||
'context window comparison',
|
||||
'OpenAI models',
|
||||
'Anthropic models',
|
||||
'Google Gemini models',
|
||||
'xAI Grok models',
|
||||
'Mistral models',
|
||||
...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`),
|
||||
],
|
||||
openGraph: {
|
||||
title: 'AI Models Directory | Sim',
|
||||
description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`,
|
||||
url: `${baseUrl}/models`,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: `${baseUrl}/models/opengraph-image`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Sim AI Models Directory',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'AI Models Directory | Sim',
|
||||
description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`,
|
||||
images: [{ url: `${baseUrl}/models/opengraph-image`, alt: 'Sim AI Models Directory' }],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/models`,
|
||||
},
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const flatModels = MODEL_CATALOG_PROVIDERS.flatMap((provider) =>
|
||||
provider.models.map((model) => ({ provider, model }))
|
||||
)
|
||||
const featuredProviders = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 6)
|
||||
const featuredModels = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
|
||||
provider.featuredModels[0] ? [{ provider, model: provider.featuredModels[0] }] : []
|
||||
).slice(0, 6)
|
||||
const heroProviders = ['openai', 'anthropic', 'azure-openai', 'google', 'bedrock']
|
||||
.map((providerId) => MODEL_CATALOG_PROVIDERS.find((provider) => provider.id === providerId))
|
||||
.filter(
|
||||
(provider): provider is (typeof MODEL_CATALOG_PROVIDERS)[number] => provider !== undefined
|
||||
)
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Sim AI Models Directory',
|
||||
description: `Directory of ${TOTAL_MODELS} AI models tracked in Sim across ${TOTAL_MODEL_PROVIDERS} providers.`,
|
||||
url: `${baseUrl}/models`,
|
||||
numberOfItems: TOTAL_MODELS,
|
||||
itemListElement: flatModels.map(({ provider, model }, index) => {
|
||||
const { lowPrice, highPrice } = getPricingBounds(model.pricing)
|
||||
return {
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
item: {
|
||||
'@type': 'Product',
|
||||
name: model.displayName,
|
||||
url: `${baseUrl}${model.href}`,
|
||||
description: model.summary,
|
||||
brand: provider.name,
|
||||
category: 'AI language model',
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
priceCurrency: 'USD',
|
||||
lowPrice: lowPrice.toString(),
|
||||
highPrice: highPrice.toString(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<div className='mx-auto max-w-[1280px] px-6 py-16 sm:px-8 md:px-12'>
|
||||
<section aria-labelledby='models-heading' className='mb-14'>
|
||||
<div className='max-w-[840px]'>
|
||||
<p className='mb-3 text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.16em]'>
|
||||
Public model directory
|
||||
</p>
|
||||
<h1
|
||||
id='models-heading'
|
||||
className='text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'
|
||||
>
|
||||
Browse AI models by provider, pricing, and capabilities
|
||||
</h1>
|
||||
<p className='mt-5 max-w-[760px] text-[18px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Explore every model tracked in Sim across providers like{' '}
|
||||
{heroProviders.map((provider, index, allProviders) => {
|
||||
const Icon = provider.icon
|
||||
|
||||
return (
|
||||
<span key={provider.id}>
|
||||
<span className='inline-flex items-center gap-1 whitespace-nowrap align-[0.02em]'>
|
||||
{Icon ? (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='relative top-[0.02em] inline-flex shrink-0 text-[var(--landing-text)]'
|
||||
>
|
||||
<Icon className='h-[0.82em] w-[0.82em]' />
|
||||
</span>
|
||||
) : null}
|
||||
<span>{provider.name}</span>
|
||||
</span>
|
||||
{index < allProviders.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{
|
||||
' and more. Compare model IDs, token pricing, context windows, and features such as reasoning, structured outputs, and deep research from one clean catalog.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Start building free
|
||||
</a>
|
||||
<Link
|
||||
href='/integrations'
|
||||
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
|
||||
>
|
||||
Explore integrations
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby='providers-heading' className='mb-16'>
|
||||
<div className='mb-6'>
|
||||
<h2
|
||||
id='providers-heading'
|
||||
className='font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Browse by provider
|
||||
</h2>
|
||||
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Each provider has its own generated SEO page with model lineup details, featured
|
||||
models, provider FAQs, and internal links to individual model pages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{featuredProviders.map((provider) => (
|
||||
<ProviderCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby='featured-models-heading' className='mb-16'>
|
||||
<div className='mb-6'>
|
||||
<h2
|
||||
id='featured-models-heading'
|
||||
className='font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
Featured model pages
|
||||
</h2>
|
||||
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
These pages are generated directly from the model registry and target high-intent
|
||||
search queries around pricing, context windows, and model capabilities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
|
||||
{featuredModels.map(({ provider, model }) => (
|
||||
<ModelCard key={model.id} provider={provider} model={model} showProvider />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby='all-models-heading'>
|
||||
<div className='mb-6'>
|
||||
<h2
|
||||
id='all-models-heading'
|
||||
className='font-[500] text-[28px] text-[var(--landing-text)]'
|
||||
>
|
||||
All models
|
||||
</h2>
|
||||
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
Search the full catalog by provider, model ID, or capability. Use it to compare
|
||||
providers, sanity-check pricing, and quickly understand which models fit the workflow
|
||||
you're building. All pricing is shown per one million tokens using the metadata
|
||||
currently tracked in Sim.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModelDirectory />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby='faq-heading'
|
||||
className='mt-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
|
||||
>
|
||||
<h2 id='faq-heading' className='font-[500] text-[28px] text-[var(--landing-text)]'>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className='mt-3'>
|
||||
<LandingFAQ faqs={faqItems} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
790
apps/sim/app/(landing)/models/utils.ts
Normal file
790
apps/sim/app/(landing)/models/utils.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
|
||||
const PROVIDER_PREFIXES: Record<string, string[]> = {
|
||||
'azure-openai': ['azure/'],
|
||||
'azure-anthropic': ['azure-anthropic/'],
|
||||
vertex: ['vertex/'],
|
||||
bedrock: ['bedrock/'],
|
||||
cerebras: ['cerebras/'],
|
||||
fireworks: ['fireworks/'],
|
||||
groq: ['groq/'],
|
||||
openrouter: ['openrouter/'],
|
||||
vllm: ['vllm/'],
|
||||
}
|
||||
|
||||
const PROVIDER_NAME_OVERRIDES: Record<string, string> = {
|
||||
deepseek: 'DeepSeek',
|
||||
vllm: 'vLLM',
|
||||
xai: 'xAI',
|
||||
}
|
||||
|
||||
const TOKEN_REPLACEMENTS: Record<string, string> = {
|
||||
ai: 'AI',
|
||||
aws: 'AWS',
|
||||
gpt: 'GPT',
|
||||
oss: 'OSS',
|
||||
llm: 'LLM',
|
||||
xai: 'xAI',
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
azure: 'Azure',
|
||||
gemini: 'Gemini',
|
||||
vertex: 'Vertex',
|
||||
groq: 'Groq',
|
||||
mistral: 'Mistral',
|
||||
deepseek: 'DeepSeek',
|
||||
cerebras: 'Cerebras',
|
||||
ollama: 'Ollama',
|
||||
bedrock: 'Bedrock',
|
||||
google: 'Google',
|
||||
moonshotai: 'Moonshot AI',
|
||||
qwen: 'Qwen',
|
||||
glm: 'GLM',
|
||||
kimi: 'Kimi',
|
||||
nova: 'Nova',
|
||||
llama: 'Llama',
|
||||
meta: 'Meta',
|
||||
cohere: 'Cohere',
|
||||
amazon: 'Amazon',
|
||||
opus: 'Opus',
|
||||
sonnet: 'Sonnet',
|
||||
haiku: 'Haiku',
|
||||
flash: 'Flash',
|
||||
preview: 'Preview',
|
||||
latest: 'Latest',
|
||||
mini: 'Mini',
|
||||
nano: 'Nano',
|
||||
pro: 'Pro',
|
||||
plus: 'Plus',
|
||||
plusplus: 'PlusPlus',
|
||||
code: 'Code',
|
||||
codex: 'Codex',
|
||||
instant: 'Instant',
|
||||
versatile: 'Versatile',
|
||||
instruct: 'Instruct',
|
||||
guard: 'Guard',
|
||||
safeguard: 'Safeguard',
|
||||
medium: 'Medium',
|
||||
small: 'Small',
|
||||
large: 'Large',
|
||||
lite: 'Lite',
|
||||
premier: 'Premier',
|
||||
premierer: 'Premier',
|
||||
micro: 'Micro',
|
||||
reasoning: 'Reasoning',
|
||||
non: 'Non',
|
||||
distill: 'Distill',
|
||||
chat: 'Chat',
|
||||
text: 'Text',
|
||||
embedding: 'Embedding',
|
||||
router: 'Router',
|
||||
}
|
||||
|
||||
export interface PricingInfo {
|
||||
input: number
|
||||
cachedInput?: number
|
||||
output: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CatalogFaq {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export interface CapabilityFact {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface CatalogModel {
|
||||
id: string
|
||||
slug: string
|
||||
href: string
|
||||
displayName: string
|
||||
shortId: string
|
||||
providerId: string
|
||||
providerName: string
|
||||
providerSlug: string
|
||||
contextWindow: number | null
|
||||
pricing: PricingInfo
|
||||
capabilities: ModelCapabilities
|
||||
capabilityTags: string[]
|
||||
summary: string
|
||||
bestFor: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export interface CatalogProvider {
|
||||
id: string
|
||||
slug: string
|
||||
href: string
|
||||
name: string
|
||||
description: string
|
||||
summary: string
|
||||
defaultModel: string
|
||||
defaultModelDisplayName: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
contextInformationAvailable: boolean
|
||||
providerCapabilityTags: string[]
|
||||
modelCount: number
|
||||
models: CatalogModel[]
|
||||
featuredModels: CatalogModel[]
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export function formatTokenCount(value?: number | null): string {
|
||||
if (value == null) {
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
if (value >= 1000000) {
|
||||
return `${trimTrailingZeros((value / 1000000).toFixed(2))}M`
|
||||
}
|
||||
|
||||
if (value >= 1000) {
|
||||
return `${trimTrailingZeros((value / 1000).toFixed(0))}k`
|
||||
}
|
||||
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function formatPrice(price?: number | null): string {
|
||||
if (price === undefined || price === null) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
const maximumFractionDigits = price > 0 && price < 0.001 ? 4 : 3
|
||||
|
||||
return `$${trimTrailingZeros(
|
||||
new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits,
|
||||
}).format(price)
|
||||
)}`
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(date: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(date))
|
||||
} catch {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCapabilityBoolean(
|
||||
value: boolean | undefined,
|
||||
{
|
||||
positive = 'Supported',
|
||||
negative = 'Not supported',
|
||||
}: {
|
||||
positive?: string
|
||||
negative?: string
|
||||
} = {}
|
||||
): string {
|
||||
return value ? positive : negative
|
||||
}
|
||||
|
||||
function trimTrailingZeros(value: string): string {
|
||||
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/--+/g, '-')
|
||||
}
|
||||
|
||||
function getProviderPrefixes(providerId: string): string[] {
|
||||
return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`]
|
||||
}
|
||||
|
||||
function stripProviderPrefix(providerId: string, modelId: string): string {
|
||||
for (const prefix of getProviderPrefixes(providerId)) {
|
||||
if (modelId.startsWith(prefix)) {
|
||||
return modelId.slice(prefix.length)
|
||||
}
|
||||
}
|
||||
|
||||
return modelId
|
||||
}
|
||||
|
||||
function stripTechnicalSuffixes(value: string): string {
|
||||
return value
|
||||
.replace(/-\d{8}-v\d+:\d+$/i, '')
|
||||
.replace(/-v\d+:\d+$/i, '')
|
||||
.replace(/-\d{8}$/i, '')
|
||||
}
|
||||
|
||||
function tokenizeModelName(value: string): string[] {
|
||||
return value
|
||||
.replace(/[./:_]+/g, '-')
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function mergeVersionTokens(tokens: string[]): string[] {
|
||||
const merged: string[] = []
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const current = tokens[index]
|
||||
const next = tokens[index + 1]
|
||||
|
||||
if (/^\d{1,2}$/.test(current) && /^\d{1,2}$/.test(next)) {
|
||||
merged.push(`${current}.${next}`)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
merged.push(current)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function formatModelToken(token: string): string {
|
||||
const normalized = token.toLowerCase()
|
||||
|
||||
if (TOKEN_REPLACEMENTS[normalized]) {
|
||||
return TOKEN_REPLACEMENTS[normalized]
|
||||
}
|
||||
|
||||
if (/^\d+b$/i.test(token)) {
|
||||
return `${token.slice(0, -1)}B`
|
||||
}
|
||||
|
||||
if (/^\d+e$/i.test(token)) {
|
||||
return `${token.slice(0, -1)}E`
|
||||
}
|
||||
|
||||
if (/^o\d+$/i.test(token)) {
|
||||
return token.toLowerCase()
|
||||
}
|
||||
|
||||
if (/^r\d+$/i.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
if (/^v\d+$/i.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
if (/^\d+\.\d+$/.test(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
if (/^[a-z]{3,}\d+$/i.test(token)) {
|
||||
const [, prefix, version] = token.match(/^([a-z]{3,})(\d+)$/i) ?? []
|
||||
if (prefix && version) {
|
||||
return `${formatModelToken(prefix)} ${version}`
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-z]\d+[a-z]$/i.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
return token.charAt(0).toUpperCase() + token.slice(1)
|
||||
}
|
||||
|
||||
function formatModelDisplayName(providerId: string, modelId: string): string {
|
||||
const shortId = stripProviderPrefix(providerId, modelId)
|
||||
const normalized = stripTechnicalSuffixes(shortId)
|
||||
const tokens = mergeVersionTokens(tokenizeModelName(normalized))
|
||||
|
||||
const displayName = tokens
|
||||
.map(formatModelToken)
|
||||
.join(' ')
|
||||
.split(/\s+/)
|
||||
.filter(
|
||||
(word, index, words) => index === 0 || word.toLowerCase() !== words[index - 1].toLowerCase()
|
||||
)
|
||||
.join(' ')
|
||||
|
||||
return displayName.replace(/^GPT (\d[\w.]*)/i, 'GPT-$1').replace(/\bGpt\b/g, 'GPT')
|
||||
}
|
||||
|
||||
function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
|
||||
const tags: string[] = []
|
||||
|
||||
if (capabilities.temperature) {
|
||||
tags.push(`Temperature ${capabilities.temperature.min}-${capabilities.temperature.max}`)
|
||||
}
|
||||
|
||||
if (capabilities.toolUsageControl) {
|
||||
tags.push('Tool choice')
|
||||
}
|
||||
|
||||
if (capabilities.nativeStructuredOutputs) {
|
||||
tags.push('Structured outputs')
|
||||
}
|
||||
|
||||
if (capabilities.computerUse) {
|
||||
tags.push('Computer use')
|
||||
}
|
||||
|
||||
if (capabilities.deepResearch) {
|
||||
tags.push('Deep research')
|
||||
}
|
||||
|
||||
if (capabilities.reasoningEffort) {
|
||||
tags.push(`Reasoning ${capabilities.reasoningEffort.values.join(', ')}`)
|
||||
}
|
||||
|
||||
if (capabilities.verbosity) {
|
||||
tags.push(`Verbosity ${capabilities.verbosity.values.join(', ')}`)
|
||||
}
|
||||
|
||||
if (capabilities.thinking) {
|
||||
tags.push(`Thinking ${capabilities.thinking.levels.join(', ')}`)
|
||||
}
|
||||
|
||||
if (capabilities.maxOutputTokens) {
|
||||
tags.push(`Max output ${formatTokenCount(capabilities.maxOutputTokens)}`)
|
||||
}
|
||||
|
||||
if (capabilities.memory === false) {
|
||||
tags.push('Memory off')
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
function buildBestForLine(model: {
|
||||
pricing: PricingInfo
|
||||
capabilities: ModelCapabilities
|
||||
contextWindow: number | null
|
||||
}): string {
|
||||
const { pricing, capabilities, contextWindow } = model
|
||||
|
||||
if (capabilities.deepResearch) {
|
||||
return 'Best for multi-step research workflows and agent-led web investigation.'
|
||||
}
|
||||
|
||||
if (capabilities.reasoningEffort || capabilities.thinking) {
|
||||
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
|
||||
}
|
||||
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
if (contextWindow && contextWindow >= 1000000) {
|
||||
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
|
||||
}
|
||||
|
||||
if (capabilities.nativeStructuredOutputs) {
|
||||
return 'Best for production workflows that need reliable typed outputs.'
|
||||
}
|
||||
|
||||
return 'Best for general-purpose AI workflows inside Sim.'
|
||||
}
|
||||
|
||||
function buildModelSummary(
|
||||
providerName: string,
|
||||
displayName: string,
|
||||
pricing: PricingInfo,
|
||||
contextWindow: number | null,
|
||||
capabilityTags: string[]
|
||||
): string {
|
||||
const parts = [
|
||||
`${displayName} is a ${providerName} model tracked in Sim.`,
|
||||
contextWindow ? `It supports a ${formatTokenCount(contextWindow)} token context window.` : null,
|
||||
`Pricing starts at ${formatPrice(pricing.input)}/1M input tokens and ${formatPrice(pricing.output)}/1M output tokens.`,
|
||||
capabilityTags.length > 0
|
||||
? `Key capabilities include ${capabilityTags.slice(0, 3).join(', ')}.`
|
||||
: null,
|
||||
]
|
||||
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function getProviderDisplayName(providerId: string, providerName: string): string {
|
||||
return PROVIDER_NAME_OVERRIDES[providerId] ?? providerName
|
||||
}
|
||||
|
||||
function computeModelRelevanceScore(model: CatalogModel): number {
|
||||
return (
|
||||
(model.capabilities.reasoningEffort ? 10 : 0) +
|
||||
(model.capabilities.thinking ? 10 : 0) +
|
||||
(model.capabilities.deepResearch ? 8 : 0) +
|
||||
(model.capabilities.nativeStructuredOutputs ? 4 : 0) +
|
||||
(model.contextWindow ?? 0) / 100000
|
||||
)
|
||||
}
|
||||
|
||||
function compareModelsByRelevance(a: CatalogModel, b: CatalogModel): number {
|
||||
return computeModelRelevanceScore(b) - computeModelRelevanceScore(a)
|
||||
}
|
||||
|
||||
const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
const providerSlug = slugify(provider.id)
|
||||
const providerDisplayName = getProviderDisplayName(provider.id, provider.name)
|
||||
const providerCapabilityTags = buildCapabilityTags(provider.capabilities ?? {})
|
||||
|
||||
const models: CatalogModel[] = provider.models.map((model) => {
|
||||
const shortId = stripProviderPrefix(provider.id, model.id)
|
||||
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
|
||||
const capabilityTags = buildCapabilityTags(mergedCapabilities)
|
||||
const displayName = formatModelDisplayName(provider.id, model.id)
|
||||
const modelSlug = slugify(shortId)
|
||||
const href = `/models/${providerSlug}/${modelSlug}`
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
slug: modelSlug,
|
||||
href,
|
||||
displayName,
|
||||
shortId,
|
||||
providerId: provider.id,
|
||||
providerName: providerDisplayName,
|
||||
providerSlug,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
capabilityTags,
|
||||
summary: buildModelSummary(
|
||||
providerDisplayName,
|
||||
displayName,
|
||||
model.pricing,
|
||||
model.contextWindow ?? null,
|
||||
capabilityTags
|
||||
),
|
||||
bestFor: buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
}),
|
||||
searchText: [
|
||||
provider.name,
|
||||
providerDisplayName,
|
||||
provider.id,
|
||||
provider.description,
|
||||
model.id,
|
||||
shortId,
|
||||
displayName,
|
||||
capabilityTags.join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase(),
|
||||
}
|
||||
})
|
||||
|
||||
const defaultModelDisplayName =
|
||||
models.find((model) => model.id === provider.defaultModel)?.displayName ||
|
||||
(provider.defaultModel ? formatModelDisplayName(provider.id, provider.defaultModel) : 'Dynamic')
|
||||
|
||||
const featuredModels = [...models].sort(compareModelsByRelevance).slice(0, 6)
|
||||
|
||||
return {
|
||||
id: provider.id,
|
||||
slug: providerSlug,
|
||||
href: `/models/${providerSlug}`,
|
||||
name: providerDisplayName,
|
||||
description: provider.description,
|
||||
summary: `${providerDisplayName} has ${models.length} tracked model${models.length === 1 ? '' : 's'} in Sim with pricing, context window, and capability metadata.`,
|
||||
defaultModel: provider.defaultModel,
|
||||
defaultModelDisplayName,
|
||||
icon: provider.icon,
|
||||
contextInformationAvailable: provider.contextInformationAvailable !== false,
|
||||
providerCapabilityTags,
|
||||
modelCount: models.length,
|
||||
models,
|
||||
featuredModels,
|
||||
searchText: [
|
||||
provider.name,
|
||||
providerDisplayName,
|
||||
provider.id,
|
||||
provider.description,
|
||||
provider.defaultModel,
|
||||
defaultModelDisplayName,
|
||||
providerCapabilityTags.join(' '),
|
||||
models.map((model) => model.displayName).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase(),
|
||||
} satisfies CatalogProvider
|
||||
})
|
||||
|
||||
function assertUniqueGeneratedRoutes(providers: CatalogProvider[]): void {
|
||||
const seenProviderHrefs = new Map<string, string>()
|
||||
const seenModelHrefs = new Map<string, string>()
|
||||
|
||||
for (const provider of providers) {
|
||||
const existingProvider = seenProviderHrefs.get(provider.href)
|
||||
if (existingProvider) {
|
||||
throw new Error(
|
||||
`Duplicate provider route detected: ${provider.href} for ${provider.id} and ${existingProvider}`
|
||||
)
|
||||
}
|
||||
seenProviderHrefs.set(provider.href, provider.id)
|
||||
|
||||
for (const model of provider.models) {
|
||||
const existingModel = seenModelHrefs.get(model.href)
|
||||
if (existingModel) {
|
||||
throw new Error(
|
||||
`Duplicate model route detected: ${model.href} for ${model.id} and ${existingModel}`
|
||||
)
|
||||
}
|
||||
seenModelHrefs.set(model.href, model.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertUniqueGeneratedRoutes(rawProviders)
|
||||
|
||||
export const MODEL_CATALOG_PROVIDERS: CatalogProvider[] = rawProviders
|
||||
export const MODEL_PROVIDERS_WITH_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
|
||||
(provider) => provider.models.length > 0
|
||||
)
|
||||
export const MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
|
||||
(provider) => provider.models.length === 0
|
||||
)
|
||||
export const ALL_CATALOG_MODELS = MODEL_PROVIDERS_WITH_CATALOGS.flatMap(
|
||||
(provider) => provider.models
|
||||
)
|
||||
export const TOTAL_MODEL_PROVIDERS = MODEL_CATALOG_PROVIDERS.length
|
||||
export const TOTAL_MODELS = ALL_CATALOG_MODELS.length
|
||||
export const MAX_CONTEXT_WINDOW = Math.max(
|
||||
...ALL_CATALOG_MODELS.map((model) => model.contextWindow ?? 0)
|
||||
)
|
||||
export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map(
|
||||
(provider) => provider.name
|
||||
)
|
||||
|
||||
export function getPricingBounds(pricing: PricingInfo): { lowPrice: number; highPrice: number } {
|
||||
return {
|
||||
lowPrice: Math.min(
|
||||
pricing.input,
|
||||
pricing.output,
|
||||
...(pricing.cachedInput !== undefined ? [pricing.cachedInput] : [])
|
||||
),
|
||||
highPrice: Math.max(pricing.input, pricing.output),
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderBySlug(providerSlug: string): CatalogProvider | null {
|
||||
return MODEL_CATALOG_PROVIDERS.find((provider) => provider.slug === providerSlug) ?? null
|
||||
}
|
||||
|
||||
export function getModelBySlug(providerSlug: string, modelSlug: string): CatalogModel | null {
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
|
||||
return provider.models.find((model) => model.slug === modelSlug) ?? null
|
||||
}
|
||||
|
||||
export function getRelatedModels(targetModel: CatalogModel, limit = 6): CatalogModel[] {
|
||||
const provider = MODEL_PROVIDERS_WITH_CATALOGS.find(
|
||||
(entry) => entry.id === targetModel.providerId
|
||||
)
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
|
||||
const targetTokens = new Set(tokenizeModelName(stripTechnicalSuffixes(targetModel.shortId)))
|
||||
|
||||
return provider.models
|
||||
.filter((model) => model.id !== targetModel.id)
|
||||
.map((model) => {
|
||||
const modelTokens = tokenizeModelName(stripTechnicalSuffixes(model.shortId))
|
||||
const sharedTokenCount = modelTokens.filter((token) => targetTokens.has(token)).length
|
||||
const sharedCapabilityCount = model.capabilityTags.filter((tag) =>
|
||||
targetModel.capabilityTags.includes(tag)
|
||||
).length
|
||||
|
||||
return {
|
||||
model,
|
||||
score: sharedTokenCount * 2 + sharedCapabilityCount + (model.contextWindow ?? 0) / 1000000,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ model }) => model)
|
||||
}
|
||||
|
||||
export function buildProviderFaqs(provider: CatalogProvider): CatalogFaq[] {
|
||||
const cheapestModel = getCheapestProviderModel(provider)
|
||||
const largestContextModel = getLargestContextProviderModel(provider)
|
||||
|
||||
return [
|
||||
{
|
||||
question: `What ${provider.name} models are available in Sim?`,
|
||||
answer: `Sim currently tracks ${provider.modelCount} ${provider.name} model${provider.modelCount === 1 ? '' : 's'} including ${provider.models
|
||||
.slice(0, 6)
|
||||
.map((model) => model.displayName)
|
||||
.join(', ')}${provider.modelCount > 6 ? ', and more' : ''}.`,
|
||||
},
|
||||
{
|
||||
question: `What is the default ${provider.name} model in Sim?`,
|
||||
answer: provider.defaultModel
|
||||
? `${provider.defaultModelDisplayName} is the default ${provider.name} model in Sim.`
|
||||
: `${provider.name} does not have a fixed default model in the public catalog because models are loaded dynamically.`,
|
||||
},
|
||||
{
|
||||
question: `What is the cheapest ${provider.name} model tracked in Sim?`,
|
||||
answer: cheapestModel
|
||||
? `${cheapestModel.displayName} currently has the lowest listed input price at ${formatPrice(
|
||||
cheapestModel.pricing.input
|
||||
)}/1M tokens.`
|
||||
: `Sim does not currently expose a fixed public pricing table for ${provider.name} models on this page.`,
|
||||
},
|
||||
{
|
||||
question: `Which ${provider.name} model has the largest context window?`,
|
||||
answer: largestContextModel?.contextWindow
|
||||
? `${largestContextModel.displayName} currently has the largest listed context window at ${formatTokenCount(
|
||||
largestContextModel.contextWindow
|
||||
)} tokens.`
|
||||
: `Context window details are not fully available for every ${provider.name} model in the public catalog.`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel): CatalogFaq[] {
|
||||
return [
|
||||
{
|
||||
question: `What is ${model.displayName}?`,
|
||||
answer: `${model.displayName} is a ${provider.name} model available in Sim. ${model.summary}`,
|
||||
},
|
||||
{
|
||||
question: `How much does ${model.displayName} cost?`,
|
||||
answer: `${model.displayName} is listed at ${formatPrice(model.pricing.input)}/1M input tokens${model.pricing.cachedInput !== undefined ? `, ${formatPrice(model.pricing.cachedInput)}/1M cached input tokens` : ''}, and ${formatPrice(model.pricing.output)}/1M output tokens.`,
|
||||
},
|
||||
{
|
||||
question: `What is the context window for ${model.displayName}?`,
|
||||
answer: model.contextWindow
|
||||
? `${model.displayName} supports a listed context window of ${formatTokenCount(model.contextWindow)} tokens in Sim.`
|
||||
: `A public context window value is not currently tracked for ${model.displayName}.`,
|
||||
},
|
||||
{
|
||||
question: `What capabilities does ${model.displayName} support?`,
|
||||
answer:
|
||||
model.capabilityTags.length > 0
|
||||
? `${model.displayName} supports ${model.capabilityTags.join(', ')}.`
|
||||
: `${model.displayName} is available in Sim, but no extra public capability flags are currently tracked for this model.`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
|
||||
const { capabilities } = model
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Temperature',
|
||||
value: capabilities.temperature
|
||||
? `${capabilities.temperature.min} to ${capabilities.temperature.max}`
|
||||
: 'Not configurable',
|
||||
},
|
||||
{
|
||||
label: 'Reasoning effort',
|
||||
value: capabilities.reasoningEffort
|
||||
? capabilities.reasoningEffort.values.join(', ')
|
||||
: 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Verbosity',
|
||||
value: capabilities.verbosity ? capabilities.verbosity.values.join(', ') : 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Thinking levels',
|
||||
value: capabilities.thinking
|
||||
? `${capabilities.thinking.levels.join(', ')}${
|
||||
capabilities.thinking.default ? ` (default: ${capabilities.thinking.default})` : ''
|
||||
}`
|
||||
: 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Structured outputs',
|
||||
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
|
||||
},
|
||||
{
|
||||
label: 'Tool choice',
|
||||
value: formatCapabilityBoolean(capabilities.toolUsageControl),
|
||||
},
|
||||
{
|
||||
label: 'Computer use',
|
||||
value: formatCapabilityBoolean(capabilities.computerUse),
|
||||
},
|
||||
{
|
||||
label: 'Deep research',
|
||||
value: formatCapabilityBoolean(capabilities.deepResearch),
|
||||
},
|
||||
{
|
||||
label: 'Memory support',
|
||||
value: capabilities.memory === false ? 'Disabled' : 'Supported',
|
||||
},
|
||||
{
|
||||
label: 'Max output tokens',
|
||||
value: capabilities.maxOutputTokens
|
||||
? formatTokenCount(capabilities.maxOutputTokens)
|
||||
: 'Standard defaults',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getCheapestProviderModel(provider: CatalogProvider): CatalogModel | null {
|
||||
return [...provider.models].sort((a, b) => a.pricing.input - b.pricing.input)[0] ?? null
|
||||
}
|
||||
|
||||
export function getLargestContextProviderModel(provider: CatalogProvider): CatalogModel | null {
|
||||
return (
|
||||
[...provider.models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0))[0] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function getProviderCapabilitySummary(provider: CatalogProvider): CapabilityFact[] {
|
||||
const reasoningCount = provider.models.filter(
|
||||
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
|
||||
).length
|
||||
const structuredCount = provider.models.filter(
|
||||
(model) => model.capabilities.nativeStructuredOutputs
|
||||
).length
|
||||
const deepResearchCount = provider.models.filter(
|
||||
(model) => model.capabilities.deepResearch
|
||||
).length
|
||||
const cheapestModel = getCheapestProviderModel(provider)
|
||||
const largestContextModel = getLargestContextProviderModel(provider)
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Reasoning-capable models',
|
||||
value: reasoningCount > 0 ? `${reasoningCount} tracked` : 'None tracked',
|
||||
},
|
||||
{
|
||||
label: 'Structured outputs',
|
||||
value: structuredCount > 0 ? `${structuredCount} tracked` : 'None tracked',
|
||||
},
|
||||
{
|
||||
label: 'Deep research models',
|
||||
value: deepResearchCount > 0 ? `${deepResearchCount} tracked` : 'None tracked',
|
||||
},
|
||||
{
|
||||
label: 'Lowest input price',
|
||||
value: cheapestModel
|
||||
? `${cheapestModel.displayName} at ${formatPrice(cheapestModel.pricing.input)}/1M`
|
||||
: 'Not available',
|
||||
},
|
||||
{
|
||||
label: 'Largest context window',
|
||||
value: largestContextModel?.contextWindow
|
||||
? `${largestContextModel.displayName} at ${formatTokenCount(largestContextModel.contextWindow)}`
|
||||
: 'Not available',
|
||||
},
|
||||
]
|
||||
}
|
||||
292
apps/sim/app/(landing)/partners/page.tsx
Normal file
292
apps/sim/app/(landing)/partners/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Partner Program',
|
||||
description:
|
||||
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Partner Program | Sim',
|
||||
description: 'Join the Sim partner program.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
const PARTNER_TIERS = [
|
||||
{
|
||||
name: 'Certified Partner',
|
||||
badge: 'Entry',
|
||||
color: '#3A3A3A',
|
||||
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
|
||||
perks: [
|
||||
'Official partner badge',
|
||||
'Listed in partner directory',
|
||||
'Early access to new features',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Silver Partner',
|
||||
badge: 'Growth',
|
||||
color: '#5A5A5A',
|
||||
requirements: [
|
||||
'All Certified requirements',
|
||||
'3+ active client deployments',
|
||||
'Sim Academy advanced certification',
|
||||
],
|
||||
perks: [
|
||||
'All Certified perks',
|
||||
'Dedicated partner Slack channel',
|
||||
'Co-marketing opportunities',
|
||||
'Priority support',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Gold Partner',
|
||||
badge: 'Premier',
|
||||
color: '#8B7355',
|
||||
requirements: [
|
||||
'All Silver requirements',
|
||||
'10+ active client deployments',
|
||||
'Sim solutions architect certification',
|
||||
],
|
||||
perks: [
|
||||
'All Silver perks',
|
||||
'Revenue share program',
|
||||
'Joint case studies',
|
||||
'Dedicated partner success manager',
|
||||
'Influence product roadmap',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const HOW_IT_WORKS = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Sign up & complete Sim Academy',
|
||||
description:
|
||||
'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI workflows through hands-on canvas exercises.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Build & deploy real solutions',
|
||||
description:
|
||||
'Put your skills to work. Build workflow automations for clients, integrate Sim into existing products, or create your own Sim-powered applications.',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Get certified & grow',
|
||||
description:
|
||||
'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.',
|
||||
},
|
||||
]
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
icon: '🎓',
|
||||
title: 'Interactive Learning',
|
||||
description:
|
||||
'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises — not just videos.',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Co-Marketing',
|
||||
description:
|
||||
'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.',
|
||||
},
|
||||
{
|
||||
icon: '💰',
|
||||
title: 'Revenue Share',
|
||||
description: 'Gold partners earn revenue share on referred customers and managed deployments.',
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Early Access',
|
||||
description:
|
||||
'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.',
|
||||
},
|
||||
{
|
||||
icon: '🛠️',
|
||||
title: 'Technical Support',
|
||||
description:
|
||||
'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.',
|
||||
},
|
||||
{
|
||||
icon: '📣',
|
||||
title: 'Community',
|
||||
description:
|
||||
'Join a growing community of Sim builders. Share workflows, collaborate on solutions, and shape the product roadmap.',
|
||||
},
|
||||
]
|
||||
|
||||
export default async function PartnersPage() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
|
||||
>
|
||||
<header>
|
||||
<Navbar logoOnly={false} blogPosts={blogPosts} />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner Program
|
||||
</div>
|
||||
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
|
||||
Build the future
|
||||
<br />
|
||||
of AI automation
|
||||
</h1>
|
||||
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
|
||||
recognition in the growing ecosystem of AI workflow builders.
|
||||
</p>
|
||||
<div className='flex items-center gap-4'>
|
||||
{/* TODO: Uncomment when academy is public */}
|
||||
{/* <Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link> */}
|
||||
<a
|
||||
href='#how-it-works'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits grid */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Why partner with Sim
|
||||
</div>
|
||||
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{BENEFITS.map((b) => (
|
||||
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
|
||||
<div className='mb-3 text-[24px]'>{b.icon}</div>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
|
||||
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
How it works
|
||||
</div>
|
||||
<div className='space-y-10'>
|
||||
{HOW_IT_WORKS.map((step) => (
|
||||
<div key={step.step} className='flex gap-8'>
|
||||
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
|
||||
{step.step}
|
||||
</div>
|
||||
<div className='pt-2'>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
|
||||
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Partner tiers */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner tiers
|
||||
</div>
|
||||
<div className='grid gap-5 lg:grid-cols-3'>
|
||||
{PARTNER_TIERS.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
|
||||
>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
|
||||
<span
|
||||
className='rounded-full px-2.5 py-0.5 text-[11px]'
|
||||
style={{
|
||||
backgroundColor: `${tier.color}33`,
|
||||
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
|
||||
border: `1px solid ${tier.color}`,
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
|
||||
Requirements
|
||||
</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.requirements.map((r) => (
|
||||
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.perks.map((p) => (
|
||||
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className='px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-3xl text-center'>
|
||||
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Ready to get started?
|
||||
</h2>
|
||||
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
|
||||
Complete Sim Academy to earn your first certification and unlock partner benefits.
|
||||
It's free to start — no credit card required.
|
||||
</p>
|
||||
{/* TODO: Uncomment when academy is public */}
|
||||
{/* <Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link> */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname.startsWith('/form') ||
|
||||
pathname.startsWith('/oauth')
|
||||
|
||||
const isDarkModePage = pathname.startsWith('/academy')
|
||||
|
||||
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={isLightModePage ? 'light' : undefined}
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
|
||||
--auth-primary-btn-bg: #ffffff;
|
||||
--auth-primary-btn-border: #ffffff;
|
||||
--auth-primary-btn-text: #000000;
|
||||
--auth-primary-btn-hover-bg: #e0e0e0;
|
||||
--auth-primary-btn-hover-border: #e0e0e0;
|
||||
--auth-primary-btn-hover-text: #000000;
|
||||
|
||||
/* z-index scale for layered UI
|
||||
Popover must be above modal so dropdowns inside modals render correctly */
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getCompletedLessons } from '@/lib/academy/local-progress'
|
||||
import type { Course } from '@/lib/academy/types'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
|
||||
|
||||
interface CourseProgressProps {
|
||||
course: Course
|
||||
courseSlug: string
|
||||
}
|
||||
|
||||
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
|
||||
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
|
||||
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
setCompletedIds(getCompletedLessons())
|
||||
}, [])
|
||||
const { data: session } = useSession()
|
||||
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
|
||||
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
|
||||
const certificate = fetchedCert ?? issuedCert
|
||||
|
||||
const allLessons = course.modules.flatMap((m) => m.lessons)
|
||||
const totalLessons = allLessons.length
|
||||
const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length
|
||||
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{completedCount > 0 && (
|
||||
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
|
||||
<div className='mb-2 flex items-center justify-between text-[13px]'>
|
||||
<span className='text-[#999]'>Your progress</span>
|
||||
<span className='text-[#ECECEC]'>
|
||||
{completedCount}/{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
|
||||
<div
|
||||
className='h-full rounded-full bg-[#ECECEC] transition-all'
|
||||
style={{ width: `${percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl space-y-10'>
|
||||
{course.modules.map((mod, modIndex) => (
|
||||
<div key={mod.id}>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
|
||||
<div className='h-px flex-1 bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
|
||||
<div className='space-y-2'>
|
||||
{mod.lessons.map((lesson) => (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/academy/${courseSlug}/${lesson.slug}`}
|
||||
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
|
||||
>
|
||||
{completedIds.has(lesson.id) ? (
|
||||
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
|
||||
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
|
||||
{lesson.videoDurationSeconds && (
|
||||
<span className='text-[#555] text-[12px]'>
|
||||
{Math.round(lesson.videoDurationSeconds / 60)} min
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{totalLessons > 0 && completedCount === totalLessons && (
|
||||
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
|
||||
{certificate ? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
|
||||
<p className='font-mono text-[#666] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/academy/certificate/${certificate.certificateNumber}`}
|
||||
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
|
||||
>
|
||||
View certificate
|
||||
<ExternalLink className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
|
||||
<p className='text-[#666] text-[13px]'>
|
||||
{session
|
||||
? error
|
||||
? 'Something went wrong. Try again.'
|
||||
: 'Claim your certificate of completion.'
|
||||
: 'Sign in to claim your certificate.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{session ? (
|
||||
<button
|
||||
type='button'
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
issueCertificate({
|
||||
courseId: course.id,
|
||||
completedLessonIds: [...completedIds],
|
||||
})
|
||||
}
|
||||
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
|
||||
>
|
||||
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
|
||||
{isPending ? 'Issuing…' : 'Get certificate'}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href='/login'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Clock, GraduationCap } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { COURSES, getCourse } from '@/lib/academy/content'
|
||||
import { CourseProgress } from './components/course-progress'
|
||||
|
||||
interface CourseDetailPageProps {
|
||||
params: Promise<{ courseSlug: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return COURSES.map((course) => ({ courseSlug: course.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CourseDetailPageProps): Promise<Metadata> {
|
||||
const { courseSlug } = await params
|
||||
const course = getCourse(courseSlug)
|
||||
if (!course) return { title: 'Course Not Found' }
|
||||
return {
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function CourseDetailPage({ params }: CourseDetailPageProps) {
|
||||
const { courseSlug } = await params
|
||||
const course = getCourse(courseSlug)
|
||||
|
||||
if (!course) notFound()
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
|
||||
>
|
||||
← All courses
|
||||
</Link>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
|
||||
{course.title}
|
||||
</h1>
|
||||
{course.description && (
|
||||
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3.5 w-3.5' />
|
||||
{course.estimatedMinutes} min total
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<GraduationCap className='h-3.5 w-3.5' />
|
||||
Certificate upon completion
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CourseProgress course={course} courseSlug={courseSlug} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { cache } from 'react'
|
||||
import { db } from '@sim/db'
|
||||
import { academyCertificate } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { AcademyCertificate } from '@/lib/academy/types'
|
||||
|
||||
interface CertificatePageProps {
|
||||
params: Promise<{ certificateNumber: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
|
||||
const { certificateNumber } = await params
|
||||
const certificate = await fetchCertificate(certificateNumber)
|
||||
if (!certificate) return { title: 'Certificate Not Found' }
|
||||
return {
|
||||
title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`,
|
||||
description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`,
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCertificate = cache(
|
||||
async (certificateNumber: string): Promise<AcademyCertificate | null> => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
return (row as unknown as AcademyCertificate) ?? null
|
||||
}
|
||||
)
|
||||
|
||||
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
function formatDate(date: string | Date) {
|
||||
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
|
||||
}
|
||||
|
||||
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex items-center justify-between px-5 py-3.5'>
|
||||
<span className='text-[#666] text-[13px]'>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function CertificatePage({ params }: CertificatePageProps) {
|
||||
const { certificateNumber } = await params
|
||||
const certificate = await fetchCertificate(certificateNumber)
|
||||
|
||||
if (!certificate) notFound()
|
||||
|
||||
return (
|
||||
<main className='flex flex-1 items-center justify-center px-6 py-20'>
|
||||
<div className='w-full max-w-2xl'>
|
||||
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
|
||||
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
|
||||
Certificate of Completion
|
||||
</div>
|
||||
|
||||
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
|
||||
{certificate.metadata?.courseTitle}
|
||||
</h1>
|
||||
|
||||
{certificate.metadata?.recipientName && (
|
||||
<p className='mb-6 text-[#999] text-[16px]'>
|
||||
Awarded to{' '}
|
||||
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{certificate.status === 'active' ? (
|
||||
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px]'>Verified</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
|
||||
<XCircle className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
|
||||
<MetaRow label='Certificate number'>
|
||||
<span className='font-mono text-[#ECECEC] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Issued'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Status'>
|
||||
<span
|
||||
className={`text-[13px] capitalize ${
|
||||
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
|
||||
}`}
|
||||
>
|
||||
{certificate.status}
|
||||
</span>
|
||||
</MetaRow>
|
||||
{certificate.expiresAt && (
|
||||
<MetaRow label='Expires'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>
|
||||
{formatDate(certificate.expiresAt)}
|
||||
</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='mt-5 text-center text-[#555] text-[13px]'>
|
||||
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
|
||||
{certificate.metadata?.courseTitle} program.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type React from 'react'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
{children}
|
||||
<Footer hideCTA />
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AcademyNotFound() {
|
||||
return (
|
||||
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
|
||||
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
|
||||
<p className='mb-8 text-[#666] text-[15px]'>
|
||||
That course or lesson doesn't exist in the Academy.
|
||||
</p>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
|
||||
>
|
||||
Back to Academy
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BookOpen, Clock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { COURSES } from '@/lib/academy/content'
|
||||
|
||||
export default function AcademyCatalogPage() {
|
||||
return (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Sim Academy
|
||||
</div>
|
||||
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
|
||||
Become a certified
|
||||
<br />
|
||||
Sim partner
|
||||
</h1>
|
||||
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Master AI workflow automation with hands-on interactive exercises on the real Sim
|
||||
canvas. Complete the program to earn your partner certification.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
|
||||
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{COURSES.map((course) => {
|
||||
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
|
||||
return (
|
||||
<Link
|
||||
key={course.id}
|
||||
href={`/academy/${course.slug}`}
|
||||
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
|
||||
>
|
||||
{course.imageUrl && (
|
||||
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
|
||||
<img
|
||||
src={course.imageUrl}
|
||||
alt={course.title}
|
||||
className='h-full w-full object-cover opacity-80'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex-1'>
|
||||
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
|
||||
{course.title}
|
||||
</h3>
|
||||
{course.description && (
|
||||
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{course.estimatedMinutes} min
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BookOpen className='h-3 w-3' />
|
||||
{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types'
|
||||
import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider'
|
||||
|
||||
interface ExerciseViewProps {
|
||||
lessonId: string
|
||||
exerciseConfig: ExerciseDefinition
|
||||
onComplete?: () => void
|
||||
videoUrl?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the sandbox canvas for an exercise lesson.
|
||||
* Completion is determined client-side by the validation engine and persisted to localStorage.
|
||||
*/
|
||||
export function ExerciseView({
|
||||
lessonId,
|
||||
exerciseConfig,
|
||||
onComplete,
|
||||
videoUrl,
|
||||
description,
|
||||
}: ExerciseViewProps) {
|
||||
const [completed, setCompleted] = useState(false)
|
||||
// Reset completion banner when the lesson changes (component is reused across exercise navigations).
|
||||
const [prevLessonId, setPrevLessonId] = useState(lessonId)
|
||||
if (prevLessonId !== lessonId) {
|
||||
setPrevLessonId(lessonId)
|
||||
setCompleted(false)
|
||||
}
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => {
|
||||
setCompleted(true)
|
||||
markLessonComplete(lessonId)
|
||||
onComplete?.()
|
||||
},
|
||||
[lessonId, onComplete]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-col overflow-hidden'>
|
||||
<SandboxCanvasProvider
|
||||
exerciseId={lessonId}
|
||||
exerciseConfig={exerciseConfig}
|
||||
onComplete={handleComplete}
|
||||
videoUrl={videoUrl}
|
||||
description={description}
|
||||
className='flex-1'
|
||||
/>
|
||||
|
||||
{completed && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
|
||||
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
Exercise complete!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface LessonQuizProps {
|
||||
lessonId: string
|
||||
quizConfig: QuizDefinition
|
||||
onPass?: () => void
|
||||
}
|
||||
|
||||
type Answers = Record<number, number | number[] | boolean>
|
||||
|
||||
interface QuizResult {
|
||||
score: number
|
||||
passed: boolean
|
||||
feedback: Array<{ correct: boolean; explanation?: string }>
|
||||
}
|
||||
|
||||
function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult {
|
||||
const feedback = questions.map((q, i) => {
|
||||
const answer = answers[i]
|
||||
let correct = false
|
||||
if (q.type === 'multiple_choice') correct = answer === q.correctIndex
|
||||
else if (q.type === 'true_false') correct = answer === q.correctAnswer
|
||||
else if (q.type === 'multi_select') {
|
||||
const selected = (answer as number[] | undefined) ?? []
|
||||
correct =
|
||||
selected.length === q.correctIndices.length &&
|
||||
selected.every((v) => q.correctIndices.includes(v))
|
||||
} else {
|
||||
const _exhaustive: never = q
|
||||
void _exhaustive
|
||||
}
|
||||
return { correct, explanation: 'explanation' in q ? q.explanation : undefined }
|
||||
})
|
||||
const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100)
|
||||
return { score, passed: score >= passingScore, feedback }
|
||||
}
|
||||
|
||||
const optionBase =
|
||||
'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default'
|
||||
|
||||
/**
|
||||
* Interactive quiz component with per-question feedback and retry support.
|
||||
* Scoring is performed entirely client-side.
|
||||
*/
|
||||
export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) {
|
||||
const [answers, setAnswers] = useState<Answers>({})
|
||||
const [result, setResult] = useState<QuizResult | null>(null)
|
||||
// Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations).
|
||||
const [prevLessonId, setPrevLessonId] = useState(lessonId)
|
||||
if (prevLessonId !== lessonId) {
|
||||
setPrevLessonId(lessonId)
|
||||
setAnswers({})
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleAnswer = (qi: number, value: number | boolean) => {
|
||||
if (!result) setAnswers((prev) => ({ ...prev, [qi]: value }))
|
||||
}
|
||||
|
||||
const handleMultiSelect = (qi: number, oi: number) => {
|
||||
if (result) return
|
||||
setAnswers((prev) => {
|
||||
const current = (prev[qi] as number[] | undefined) ?? []
|
||||
const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi]
|
||||
return { ...prev, [qi]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const allAnswered = quizConfig.questions.every((q, i) => {
|
||||
if (q.type === 'multi_select')
|
||||
return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0
|
||||
return answers[i] !== undefined
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore)
|
||||
setResult(scored)
|
||||
if (scored.passed) {
|
||||
markLessonComplete(lessonId)
|
||||
onPass?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
|
||||
<p className='mt-1 text-[#666] text-[14px]'>
|
||||
Score {quizConfig.passingScore}% or higher to pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quizConfig.questions.map((q, qi) => {
|
||||
const feedback = result?.feedback[qi]
|
||||
const isCorrect = feedback?.correct
|
||||
|
||||
return (
|
||||
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
|
||||
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
|
||||
|
||||
{q.type === 'multiple_choice' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
answers[qi] === oi
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
oi === q.correctIndex &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === oi &&
|
||||
oi !== q.correctIndex &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'true_false' && (
|
||||
<div className='flex gap-3'>
|
||||
{(['True', 'False'] as const).map((label) => {
|
||||
const val = label === 'True'
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, val)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
|
||||
answers[qi] === val
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
val === q.correctAnswer &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === val &&
|
||||
val !== q.correctAnswer &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'multi_select' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => {
|
||||
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleMultiSelect(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
selected
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
q.correctIndices.includes(oi) &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
selected &&
|
||||
!q.correctIndices.includes(oi) &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
|
||||
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
) : (
|
||||
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
)}
|
||||
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[8px] border p-5',
|
||||
result.passed
|
||||
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
|
||||
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
|
||||
<p className='mt-1 text-[13px] opacity-80'>
|
||||
Score: {result.score}% (passing: {quizConfig.passingScore}%)
|
||||
</p>
|
||||
{!result.passed && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setAnswers({})
|
||||
setResult(null)
|
||||
}}
|
||||
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
|
||||
>
|
||||
Submit answers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
interface LessonLayoutProps {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ courseSlug: string; lessonSlug: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side auth gate for lesson pages.
|
||||
* Redirects unauthenticated users to login before any client JS runs.
|
||||
*/
|
||||
export default async function LessonLayout({ children, params }: LessonLayoutProps) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
const { courseSlug, lessonSlug } = await params
|
||||
redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getCourse } from '@/lib/academy/content'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { Lesson } from '@/lib/academy/types'
|
||||
import { LessonVideo } from '@/app/academy/components/lesson-video'
|
||||
import { ExerciseView } from './components/exercise-view'
|
||||
import { LessonQuiz } from './components/lesson-quiz'
|
||||
|
||||
const navBtnClass =
|
||||
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
|
||||
|
||||
interface LessonPageProps {
|
||||
params: Promise<{ courseSlug: string; lessonSlug: string }>
|
||||
}
|
||||
|
||||
export default function LessonPage({ params }: LessonPageProps) {
|
||||
const { courseSlug, lessonSlug } = use(params)
|
||||
const course = getCourse(courseSlug)
|
||||
const [exerciseComplete, setExerciseComplete] = useState(false)
|
||||
const [quizComplete, setQuizComplete] = useState(false)
|
||||
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
|
||||
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
|
||||
if (prevLessonSlug !== lessonSlug) {
|
||||
setPrevLessonSlug(lessonSlug)
|
||||
setExerciseComplete(false)
|
||||
setQuizComplete(false)
|
||||
}
|
||||
|
||||
const allLessons = useMemo<Lesson[]>(
|
||||
() => course?.modules.flatMap((m) => m.lessons) ?? [],
|
||||
[course]
|
||||
)
|
||||
|
||||
const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug)
|
||||
const lesson = allLessons[currentIndex]
|
||||
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
|
||||
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
|
||||
|
||||
const handleExerciseComplete = useCallback(() => setExerciseComplete(true), [])
|
||||
const handleQuizPass = useCallback(() => setQuizComplete(true), [])
|
||||
const canAdvance =
|
||||
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
|
||||
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
|
||||
? exerciseComplete && quizComplete
|
||||
: lesson?.exerciseConfig
|
||||
? exerciseComplete
|
||||
: quizComplete)
|
||||
|
||||
const isUngatedLesson =
|
||||
lesson?.lessonType === 'video' ||
|
||||
(lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig)
|
||||
|
||||
useEffect(() => {
|
||||
if (isUngatedLesson && lesson) {
|
||||
markLessonComplete(lesson.id)
|
||||
}
|
||||
}, [lesson?.id, isUngatedLesson])
|
||||
|
||||
if (!course || !lesson) {
|
||||
return (
|
||||
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
|
||||
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasVideo = Boolean(lesson.videoUrl)
|
||||
const hasExercise = Boolean(lesson.exerciseConfig)
|
||||
const hasQuiz = Boolean(lesson.quizConfig)
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
|
||||
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
|
||||
<div className='flex items-center gap-3 text-[13px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/b&w/text/b&w.svg'
|
||||
alt='Sim'
|
||||
width={40}
|
||||
height={14}
|
||||
className='opacity-70 invert transition-opacity hover:opacity-100'
|
||||
/>
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
|
||||
Academy
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link
|
||||
href={`/academy/${courseSlug}`}
|
||||
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{prevLesson ? (
|
||||
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Course
|
||||
</Link>
|
||||
)}
|
||||
{nextLesson && (
|
||||
<Link
|
||||
href={`/academy/${courseSlug}/${nextLesson.slug}`}
|
||||
onClick={(e) => {
|
||||
if (!canAdvance) e.preventDefault()
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${
|
||||
canAdvance
|
||||
? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white'
|
||||
: 'cursor-not-allowed border border-[#2A2A2A] text-[#444]'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='flex min-h-0 flex-1 overflow-hidden'>
|
||||
{lesson.lessonType === 'video' && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'exercise' && hasExercise && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'quiz' && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-2xl'>
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'mixed' && (
|
||||
<>
|
||||
{hasExercise && (!exerciseComplete || !hasQuiz) && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
|
||||
description={!hasQuiz ? lesson.description : undefined}
|
||||
/>
|
||||
)}
|
||||
{hasExercise && exerciseComplete && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && !hasQuiz && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
interface LessonVideoProps {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function LessonVideo({ url, title }: LessonVideoProps) {
|
||||
const embedUrl = resolveEmbedUrl(url)
|
||||
|
||||
if (!embedUrl) {
|
||||
return (
|
||||
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
|
||||
Video unavailable
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={title}
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
className='h-full w-full border-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveEmbedUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
|
||||
if (parsed.hostname === 'youtu.be') {
|
||||
return `https://www.youtube.com/embed${parsed.pathname}`
|
||||
}
|
||||
if (parsed.hostname.includes('youtube.com')) {
|
||||
// Shorts: youtube.com/shorts/VIDEO_ID
|
||||
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
|
||||
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
|
||||
const v = parsed.searchParams.get('v')
|
||||
if (v) return `https://www.youtube.com/embed/${v}`
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'vimeo.com') {
|
||||
const id = parsed.pathname.replace(/^\//, '')
|
||||
if (id) return `https://player.vimeo.com/video/${id}`
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (_e: unknown) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
443
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
443
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
|
||||
import type {
|
||||
ExerciseBlockState,
|
||||
ExerciseDefinition,
|
||||
ExerciseEdgeState,
|
||||
ValidationResult,
|
||||
} from '@/lib/academy/types'
|
||||
import { validateExercise } from '@/lib/academy/validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { workflowKeys } from '@/hooks/queries/workflows'
|
||||
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { LessonVideo } from './lesson-video'
|
||||
import { ValidationChecklist } from './validation-checklist'
|
||||
|
||||
const logger = createLogger('SandboxCanvasProvider')
|
||||
|
||||
const SANDBOX_WORKSPACE_ID = 'sandbox'
|
||||
|
||||
interface SandboxCanvasProviderProps {
|
||||
/** Unique ID for this exercise instance */
|
||||
exerciseId: string
|
||||
/** Full exercise configuration */
|
||||
exerciseConfig: ExerciseDefinition
|
||||
/**
|
||||
* Called when all validation rules pass for the first time.
|
||||
* Receives the current canvas state so the caller can persist it.
|
||||
*/
|
||||
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
|
||||
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
|
||||
videoUrl?: string
|
||||
/** Optional description shown below the video (or below checklist if no video) */
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
|
||||
* Looks up each block type in the registry to construct proper sub-block and output maps.
|
||||
*/
|
||||
function buildWorkflowState(
|
||||
initialBlocks: ExerciseBlockState[],
|
||||
initialEdges: ExerciseEdgeState[]
|
||||
): WorkflowState {
|
||||
const blocks: Record<string, BlockState> = {}
|
||||
|
||||
for (const exerciseBlock of initialBlocks) {
|
||||
const config = getBlock(exerciseBlock.type)
|
||||
if (!config) {
|
||||
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
|
||||
continue
|
||||
}
|
||||
|
||||
const subBlocks: Record<string, SubBlockState> = {}
|
||||
for (const sb of config.subBlocks ?? []) {
|
||||
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
|
||||
subBlocks[sb.id] = {
|
||||
id: sb.id,
|
||||
type: sb.type,
|
||||
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
|
||||
triggerMode: false,
|
||||
preferToolOutputs: true,
|
||||
})
|
||||
|
||||
blocks[exerciseBlock.id] = {
|
||||
id: exerciseBlock.id,
|
||||
type: exerciseBlock.type,
|
||||
name: config.name,
|
||||
position: exerciseBlock.position,
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
height: 0,
|
||||
locked: exerciseBlock.locked ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = initialEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle,
|
||||
targetHandle: e.targetHandle,
|
||||
type: 'default',
|
||||
data: {},
|
||||
}))
|
||||
|
||||
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current canvas state from the workflow store and converts it to
|
||||
* the exercise block/edge format used by the validation engine.
|
||||
*/
|
||||
function readCurrentCanvasState(workflowId: string): {
|
||||
blocks: ExerciseBlockState[]
|
||||
edges: ExerciseEdgeState[]
|
||||
} {
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
|
||||
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
|
||||
const blockValues = storedValues[block.id] ?? {}
|
||||
const subBlocks: Record<string, unknown> = { ...blockValues }
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
position: block.position,
|
||||
subBlocks,
|
||||
}
|
||||
})
|
||||
|
||||
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? undefined,
|
||||
targetHandle: e.targetHandle ?? undefined,
|
||||
}))
|
||||
|
||||
return { blocks, edges }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
|
||||
*
|
||||
* - Pre-hydrates workflow stores directly (no API calls)
|
||||
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
|
||||
* - Displays a constrained block toolbar and live validation checklist
|
||||
* - Supports mock execution to simulate workflow runs
|
||||
*/
|
||||
export function SandboxCanvasProvider({
|
||||
exerciseId,
|
||||
exerciseConfig,
|
||||
onComplete,
|
||||
videoUrl,
|
||||
description,
|
||||
className,
|
||||
}: SandboxCanvasProviderProps) {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult>({
|
||||
passed: false,
|
||||
results: [],
|
||||
})
|
||||
const [hintIndex, setHintIndex] = useState(-1)
|
||||
const completedRef = useRef(false)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
const isMockRunningRef = useRef(false)
|
||||
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
|
||||
|
||||
// Stable exercise ID — used as the workflow ID in the stores
|
||||
const workflowId = `sandbox-${exerciseId}`
|
||||
|
||||
const runValidation = useCallback(() => {
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
|
||||
setValidationResult((prev) => {
|
||||
if (
|
||||
prev.passed === result.passed &&
|
||||
prev.results.length === result.results.length &&
|
||||
prev.results.every((r, i) => r.passed === result.results[i].passed)
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
if (result.passed && !completedRef.current) {
|
||||
completedRef.current = true
|
||||
onCompleteRef.current?.(blocks, edges)
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules])
|
||||
|
||||
useEffect(() => {
|
||||
completedRef.current = false
|
||||
setHintIndex(-1)
|
||||
|
||||
const workflowState = buildWorkflowState(
|
||||
exerciseConfig.initialBlocks ?? [],
|
||||
exerciseConfig.initialEdges ?? []
|
||||
)
|
||||
|
||||
const syntheticMetadata: WorkflowMetadata = {
|
||||
id: workflowId,
|
||||
name: 'Exercise',
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
color: '#3972F6',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
sortOrder: 0,
|
||||
isSandbox: true,
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().replaceWorkflowState(workflowState)
|
||||
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
|
||||
|
||||
const qc = getQueryClient()
|
||||
const cacheKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
|
||||
const cached = qc.getQueryData<WorkflowMetadata[]>(cacheKey) ?? []
|
||||
qc.setQueryData(cacheKey, [...cached.filter((w) => w.id !== workflowId), syntheticMetadata])
|
||||
|
||||
useWorkflowRegistry.setState({
|
||||
activeWorkflowId: workflowId,
|
||||
hydration: {
|
||||
phase: 'ready',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
workflowId,
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Sandbox stores hydrated', { workflowId })
|
||||
setIsReady(true)
|
||||
|
||||
// Coalesce rapid store updates so validation runs at most once per animation frame.
|
||||
let rafId: number | null = null
|
||||
const scheduleValidation = () => {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
runValidation()
|
||||
})
|
||||
}
|
||||
|
||||
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
|
||||
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
|
||||
|
||||
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
|
||||
// and returns immediately (no API call). Detect that signal here and run mock execution.
|
||||
const unsubExecution = useExecutionStore.subscribe((state) => {
|
||||
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
|
||||
if (isExec && !isMockRunningRef.current) {
|
||||
void handleMockRunRef.current()
|
||||
}
|
||||
})
|
||||
|
||||
runValidation()
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
unsubWorkflow()
|
||||
unsubSubBlock()
|
||||
unsubExecution()
|
||||
const cleanupQc = getQueryClient()
|
||||
const cleanupKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
|
||||
const cleanupCached = cleanupQc.getQueryData<WorkflowMetadata[]>(cleanupKey) ?? []
|
||||
cleanupQc.setQueryData(
|
||||
cleanupKey,
|
||||
cleanupCached.filter((w) => w.id !== workflowId)
|
||||
)
|
||||
|
||||
useWorkflowRegistry.setState((state) => ({
|
||||
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
|
||||
hydration:
|
||||
state.hydration.workflowId === workflowId
|
||||
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
|
||||
: state.hydration,
|
||||
}))
|
||||
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
|
||||
useSubBlockStore.setState((state) => {
|
||||
const { [workflowId]: _removed, ...rest } = state.workflowValues
|
||||
return { workflowValues: rest }
|
||||
})
|
||||
}
|
||||
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
|
||||
|
||||
const handleMockRun = useCallback(async () => {
|
||||
if (isMockRunningRef.current) return
|
||||
isMockRunningRef.current = true
|
||||
|
||||
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
setValidationResult(result)
|
||||
if (!result.passed) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
|
||||
if (plan.length === 0) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
|
||||
const workflowBlocks = useWorkflowStore.getState().blocks
|
||||
|
||||
setIsExecuting(workflowId, true)
|
||||
clearWorkflowConsole(workflowId)
|
||||
useTerminalConsoleStore.setState({ isOpen: true })
|
||||
|
||||
try {
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const step = plan[i]
|
||||
setActiveBlocks(workflowId, new Set([step.blockId]))
|
||||
await new Promise((resolve) => setTimeout(resolve, step.delay))
|
||||
addConsole({
|
||||
workflowId,
|
||||
blockId: step.blockId,
|
||||
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
|
||||
blockType: step.blockType,
|
||||
executionOrder: i,
|
||||
output: step.output,
|
||||
success: true,
|
||||
durationMs: step.delay,
|
||||
})
|
||||
setActiveBlocks(workflowId, new Set())
|
||||
}
|
||||
} finally {
|
||||
setIsExecuting(workflowId, false)
|
||||
isMockRunningRef.current = false
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
|
||||
handleMockRunRef.current = handleMockRun
|
||||
|
||||
const handleShowHint = useCallback(() => {
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
if (hints.length === 0) return
|
||||
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
|
||||
}, [exerciseConfig.hints])
|
||||
|
||||
const handlePrevHint = useCallback(() => {
|
||||
setHintIndex((i) => Math.max(i - 1, 0))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
|
||||
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
|
||||
|
||||
return (
|
||||
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
|
||||
<GlobalCommandsProvider>
|
||||
<SandboxWorkspacePermissionsProvider>
|
||||
<div className={cn('flex h-full w-full overflow-hidden', className)}>
|
||||
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
|
||||
{(videoUrl || description) && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
|
||||
{description && (
|
||||
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
|
||||
)}
|
||||
<div className='border-[#1F1F1F] border-t' />
|
||||
</div>
|
||||
)}
|
||||
{exerciseConfig.instructions && (
|
||||
<p className='text-[#999] text-[11px] leading-relaxed'>
|
||||
{exerciseConfig.instructions}
|
||||
</p>
|
||||
)}
|
||||
<ValidationChecklist
|
||||
results={validationResult.results}
|
||||
allPassed={validationResult.passed}
|
||||
/>
|
||||
|
||||
<div className='mt-auto flex flex-col gap-2'>
|
||||
{currentHint && (
|
||||
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<span className='font-[430] text-[#666]'>
|
||||
Hint {hintIndex + 1}/{hints.length}
|
||||
</span>
|
||||
<div className='flex gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePrevHint}
|
||||
disabled={hintIndex === 0}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Previous hint'
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
disabled={hintIndex === hints.length - 1}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Next hint'
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className='text-[#ECECEC]'>{currentHint}</span>
|
||||
</div>
|
||||
)}
|
||||
{hints.length > 0 && hintIndex < 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Show hint
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
|
||||
</div>
|
||||
</div>
|
||||
</SandboxWorkspacePermissionsProvider>
|
||||
</GlobalCommandsProvider>
|
||||
</SandboxBlockConstraintsContext.Provider>
|
||||
)
|
||||
}
|
||||
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Circle } from 'lucide-react'
|
||||
import type { ValidationRuleResult } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface ValidationChecklistProps {
|
||||
results: ValidationRuleResult[]
|
||||
allPassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checklist showing exercise validation rules and their current pass/fail state.
|
||||
* Rendered inside the exercise sidebar, not as a canvas overlay.
|
||||
*/
|
||||
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
|
||||
if (results.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-2.5 flex items-center gap-1.5'>
|
||||
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
|
||||
{allPassed && (
|
||||
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{results.map((result, i) => (
|
||||
<li key={i} className='flex items-start gap-2'>
|
||||
{result.passed ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] leading-tight',
|
||||
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
|
||||
)}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
apps/sim/app/academy/layout.tsx
Normal file
33
apps/sim/app/academy/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type React from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
// TODO: Remove notFound() call to make academy pages public once content is ready
|
||||
const ACADEMY_ENABLED = false
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Sim Academy',
|
||||
template: '%s | Sim Academy',
|
||||
},
|
||||
description:
|
||||
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Sim Academy',
|
||||
description: 'Become a certified Sim partner.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!ACADEMY_ENABLED) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { db } from '@sim/db'
|
||||
import { academyCertificate, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getCourseById } from '@/lib/academy/content'
|
||||
import type { CertificateMetadata } from '@/lib/academy/types'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
|
||||
const logger = createLogger('AcademyCertificatesAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const CERT_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 5,
|
||||
refillRate: 1,
|
||||
refillIntervalMs: 60 * 60_000, // 1 per hour refill
|
||||
}
|
||||
|
||||
const IssueCertificateSchema = z.object({
|
||||
courseId: z.string(),
|
||||
completedLessonIds: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/academy/certificates
|
||||
* Issues a certificate for the given course after verifying all lessons are completed.
|
||||
* Completion is client-attested: the client sends completed lesson IDs and the server
|
||||
* validates them against the full lesson list for the course.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { allowed } = await rateLimiter.checkRateLimitDirect(
|
||||
`academy:cert:${session.user.id}`,
|
||||
CERT_RATE_LIMIT
|
||||
)
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = IssueCertificateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const { courseId, completedLessonIds } = parsed.data
|
||||
|
||||
const course = getCourseById(courseId)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify all lessons in the course are reported as completed
|
||||
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
|
||||
const completedSet = new Set(completedLessonIds)
|
||||
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
|
||||
if (incomplete.length > 0) {
|
||||
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
|
||||
}
|
||||
|
||||
const [existing, learner] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
])
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'active') {
|
||||
return NextResponse.json({ certificate: existing })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const certificateNumber = generateCertificateNumber()
|
||||
const metadata: CertificateMetadata = {
|
||||
recipientName: learner?.name ?? session.user.name ?? 'Partner',
|
||||
courseTitle: course.title,
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.insert(academyCertificate)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
status: 'active',
|
||||
certificateNumber,
|
||||
metadata,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
|
||||
if (!certificate) {
|
||||
const [race] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (race?.status === 'active') {
|
||||
return NextResponse.json({ certificate: race })
|
||||
}
|
||||
if (race) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Certificate issued', {
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
certificateNumber,
|
||||
})
|
||||
|
||||
return NextResponse.json({ certificate }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to issue certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
|
||||
* Public endpoint for verifying a certificate by its number.
|
||||
*
|
||||
* GET /api/academy/certificates?courseId=...
|
||||
* Authenticated endpoint for looking up the current user's certificate for a course.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const certificateNumber = searchParams.get('certificateNumber')
|
||||
const courseId = searchParams.get('courseId')
|
||||
|
||||
if (certificateNumber) {
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
|
||||
if (!certificate) {
|
||||
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ certificate })
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ certificate: certificate ?? null })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'certificateNumber or courseId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
|
||||
function generateCertificateNumber(): string {
|
||||
const year = new Date().getFullYear()
|
||||
return `SIM-${year}-${nanoid(8).toUpperCase()}`
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getServiceAccountProviderForProviderId,
|
||||
} from '@/lib/oauth/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -36,7 +39,8 @@ function toCredentialResponse(
|
||||
displayName: string,
|
||||
providerId: string,
|
||||
updatedAt: Date,
|
||||
scope: string | null
|
||||
scope: string | null,
|
||||
credentialType: 'oauth' | 'service_account' = 'oauth'
|
||||
) {
|
||||
const storedScope = scope?.trim()
|
||||
// Some providers (e.g. Box) don't return scopes in their token response,
|
||||
@@ -52,6 +56,7 @@ function toCredentialResponse(
|
||||
id,
|
||||
name: displayName,
|
||||
provider: providerId,
|
||||
type: credentialType,
|
||||
lastUsed: updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
scopes,
|
||||
@@ -149,6 +154,7 @@ export async function GET(request: NextRequest) {
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
accountId: credential.accountId,
|
||||
updatedAt: credential.updatedAt,
|
||||
accountProviderId: account.providerId,
|
||||
accountScope: account.scope,
|
||||
accountUpdatedAt: account.updatedAt,
|
||||
@@ -159,6 +165,49 @@ export async function GET(request: NextRequest) {
|
||||
.limit(1)
|
||||
|
||||
if (platformCredential) {
|
||||
if (platformCredential.type === 'service_account') {
|
||||
if (
|
||||
workflowId &&
|
||||
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
const [membership] = await db
|
||||
.select({ id: credentialMember.id })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, platformCredential.id),
|
||||
eq(credentialMember.userId, requesterUserId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
credentials: [
|
||||
toCredentialResponse(
|
||||
platformCredential.id,
|
||||
platformCredential.displayName,
|
||||
platformCredential.providerId || 'google-service-account',
|
||||
platformCredential.updatedAt,
|
||||
null,
|
||||
'service_account'
|
||||
),
|
||||
],
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
}
|
||||
@@ -238,14 +287,52 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
credentials: credentialsData.map((row) =>
|
||||
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
|
||||
),
|
||||
},
|
||||
{ status: 200 }
|
||||
const results = credentialsData.map((row) =>
|
||||
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
|
||||
)
|
||||
|
||||
const saProviderId = getServiceAccountProviderForProviderId(providerParam)
|
||||
|
||||
if (saProviderId) {
|
||||
const serviceAccountCreds = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
updatedAt: credential.updatedAt,
|
||||
})
|
||||
.from(credential)
|
||||
.innerJoin(
|
||||
credentialMember,
|
||||
and(
|
||||
eq(credentialMember.credentialId, credential.id),
|
||||
eq(credentialMember.userId, requesterUserId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, effectiveWorkspaceId),
|
||||
eq(credential.type, 'service_account'),
|
||||
eq(credential.providerId, saProviderId)
|
||||
)
|
||||
)
|
||||
|
||||
for (const sa of serviceAccountCreds) {
|
||||
results.push(
|
||||
toCredentialResponse(
|
||||
sa.id,
|
||||
sa.displayName,
|
||||
sa.providerId || saProviderId,
|
||||
sa.updatedAt,
|
||||
null,
|
||||
'service_account'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: results }, { status: 200 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
mockGetCredential,
|
||||
mockRefreshTokenIfNeeded,
|
||||
mockGetOAuthToken,
|
||||
mockResolveOAuthAccountId,
|
||||
mockGetServiceAccountToken,
|
||||
mockAuthorizeCredentialUse,
|
||||
mockCheckSessionOrInternalAuth,
|
||||
mockLogger,
|
||||
@@ -29,6 +31,8 @@ const {
|
||||
mockGetCredential: vi.fn(),
|
||||
mockRefreshTokenIfNeeded: vi.fn(),
|
||||
mockGetOAuthToken: vi.fn(),
|
||||
mockResolveOAuthAccountId: vi.fn(),
|
||||
mockGetServiceAccountToken: vi.fn(),
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockCheckSessionOrInternalAuth: vi.fn(),
|
||||
mockLogger: logger,
|
||||
@@ -40,6 +44,8 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
getCredential: mockGetCredential,
|
||||
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
|
||||
getOAuthToken: mockGetOAuthToken,
|
||||
resolveOAuthAccountId: mockResolveOAuthAccountId,
|
||||
getServiceAccountToken: mockGetServiceAccountToken,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
@@ -50,6 +56,10 @@ vi.mock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUse: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: vi.fn(),
|
||||
@@ -62,6 +72,7 @@ import { GET, POST } from '@/app/api/auth/oauth/token/route'
|
||||
describe('OAuth Token API Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveOAuthAccountId.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,13 @@ import { z } from 'zod'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
getCredential,
|
||||
getOAuthToken,
|
||||
getServiceAccountToken,
|
||||
refreshTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,6 +24,8 @@ const tokenRequestSchema = z
|
||||
credentialAccountUserId: z.string().min(1).optional(),
|
||||
providerId: z.string().min(1).optional(),
|
||||
workflowId: z.string().min(1).nullish(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
impersonateEmail: z.string().email().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
|
||||
@@ -63,7 +71,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
|
||||
const {
|
||||
credentialId,
|
||||
credentialAccountUserId,
|
||||
providerId,
|
||||
workflowId,
|
||||
scopes,
|
||||
impersonateEmail,
|
||||
} = parseResult.data
|
||||
|
||||
if (credentialAccountUserId && providerId) {
|
||||
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
|
||||
@@ -112,6 +127,31 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
|
||||
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId: workflowId ?? undefined,
|
||||
requireWorkflowIdForInternal: false,
|
||||
callerUserId,
|
||||
})
|
||||
if (!authz.ok) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
scopes ?? [],
|
||||
impersonateEmail
|
||||
)
|
||||
return NextResponse.json({ accessToken }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Service account token error:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId: workflowId ?? undefined,
|
||||
|
||||
@@ -160,7 +160,12 @@ describe('OAuth Utils', () => {
|
||||
|
||||
describe('refreshAccessTokenIfNeeded', () => {
|
||||
it('should return valid access token without refresh if not expired', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'valid-token',
|
||||
@@ -169,7 +174,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
@@ -179,7 +184,12 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should refresh token when expired', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
@@ -188,7 +198,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
mockUpdateChain()
|
||||
|
||||
@@ -215,7 +225,12 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should return null if refresh fails', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
@@ -224,7 +239,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createSign } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { account, credential, credentialSetMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getMicrosoftRefreshTokenExpiry,
|
||||
@@ -11,6 +13,16 @@ import {
|
||||
|
||||
const logger = createLogger('OAuthUtilsAPI')
|
||||
|
||||
export class ServiceAccountTokenError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly errorDescription: string
|
||||
) {
|
||||
super(errorDescription)
|
||||
this.name = 'ServiceAccountTokenError'
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountInsertData {
|
||||
id: string
|
||||
userId: string
|
||||
@@ -25,16 +37,26 @@ interface AccountInsertData {
|
||||
accessTokenExpiresAt?: Date
|
||||
}
|
||||
|
||||
export interface ResolvedCredential {
|
||||
accountId: string
|
||||
workspaceId?: string
|
||||
usedCredentialTable: boolean
|
||||
credentialType?: string
|
||||
credentialId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a credential ID to its underlying account ID.
|
||||
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
|
||||
* For service_account credentials, returns credentialId and type instead of accountId.
|
||||
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
|
||||
*/
|
||||
export async function resolveOAuthAccountId(
|
||||
credentialId: string
|
||||
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
|
||||
): Promise<ResolvedCredential | null> {
|
||||
const [credentialRow] = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
accountId: credential.accountId,
|
||||
workspaceId: credential.workspaceId,
|
||||
@@ -44,6 +66,16 @@ export async function resolveOAuthAccountId(
|
||||
.limit(1)
|
||||
|
||||
if (credentialRow) {
|
||||
if (credentialRow.type === 'service_account') {
|
||||
return {
|
||||
accountId: '',
|
||||
credentialId: credentialRow.id,
|
||||
credentialType: 'service_account',
|
||||
workspaceId: credentialRow.workspaceId,
|
||||
usedCredentialTable: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
|
||||
return null
|
||||
}
|
||||
@@ -57,6 +89,124 @@ export async function resolveOAuthAccountId(
|
||||
return { accountId: credentialId, usedCredentialTable: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Userinfo scopes are excluded because service accounts don't represent a user
|
||||
* and cannot request user identity information. Google rejects token requests
|
||||
* that include these scopes for service account credentials.
|
||||
*/
|
||||
const SA_EXCLUDED_SCOPES = new Set([
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
])
|
||||
|
||||
/**
|
||||
* Generates a short-lived access token for a Google service account credential
|
||||
* using the two-legged OAuth JWT flow (RFC 7523).
|
||||
*
|
||||
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
|
||||
* where the service account must impersonate a domain user via domain-wide delegation.
|
||||
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
|
||||
* authenticates directly with its own IAM permissions.
|
||||
*/
|
||||
export async function getServiceAccountToken(
|
||||
credentialId: string,
|
||||
scopes: string[],
|
||||
impersonateEmail?: string
|
||||
): Promise<string> {
|
||||
const [credentialRow] = await db
|
||||
.select({
|
||||
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
|
||||
})
|
||||
.from(credential)
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentialRow?.encryptedServiceAccountKey) {
|
||||
throw new Error('Service account key not found')
|
||||
}
|
||||
|
||||
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
|
||||
const keyData = JSON.parse(decrypted) as {
|
||||
client_email: string
|
||||
private_key: string
|
||||
token_uri?: string
|
||||
}
|
||||
|
||||
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
|
||||
const tokenUri =
|
||||
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
|
||||
? keyData.token_uri
|
||||
: 'https://oauth2.googleapis.com/token'
|
||||
|
||||
const header = { alg: 'RS256', typ: 'JWT' }
|
||||
const payload: Record<string, unknown> = {
|
||||
iss: keyData.client_email,
|
||||
scope: filteredScopes.join(' '),
|
||||
aud: tokenUri,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}
|
||||
|
||||
if (impersonateEmail) {
|
||||
payload.sub = impersonateEmail
|
||||
}
|
||||
|
||||
logger.info('Service account JWT payload', {
|
||||
iss: keyData.client_email,
|
||||
sub: impersonateEmail || '(none)',
|
||||
scopes: filteredScopes.join(' '),
|
||||
aud: tokenUri,
|
||||
})
|
||||
|
||||
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
|
||||
|
||||
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
|
||||
|
||||
const signer = createSign('RSA-SHA256')
|
||||
signer.update(signingInput)
|
||||
const signature = signer.sign(keyData.private_key, 'base64url')
|
||||
|
||||
const jwt = `${signingInput}.${signature}`
|
||||
|
||||
const response = await fetch(tokenUri, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: jwt,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text()
|
||||
logger.error('Service account token exchange failed', {
|
||||
status: response.status,
|
||||
body: errorBody,
|
||||
})
|
||||
let description = `Token exchange failed: ${response.status}`
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody) as { error_description?: string }
|
||||
if (parsed.error_description) {
|
||||
const raw = parsed.error_description
|
||||
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
|
||||
description = 'Invalid account credentials.'
|
||||
} else {
|
||||
description = raw
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// use default description
|
||||
}
|
||||
throw new ServiceAccountTokenError(response.status, description)
|
||||
}
|
||||
|
||||
const tokenData = (await response.json()) as { access_token: string }
|
||||
return tokenData.access_token
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely inserts an account record, handling duplicate constraint violations gracefully.
|
||||
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
|
||||
@@ -81,19 +231,13 @@ export async function safeAccountInsert(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID and verify it belongs to the user
|
||||
* Get a credential by resolved account ID and verify it belongs to the user.
|
||||
*/
|
||||
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
|
||||
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
@@ -103,10 +247,22 @@ export async function getCredential(requestId: string, credentialId: string, use
|
||||
|
||||
return {
|
||||
...credentials[0],
|
||||
resolvedCredentialId: resolved.accountId,
|
||||
resolvedCredentialId: accountId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID and verify it belongs to the user.
|
||||
*/
|
||||
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
|
||||
return undefined
|
||||
}
|
||||
return getCredentialByAccountId(requestId, resolved.accountId, userId)
|
||||
}
|
||||
|
||||
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
|
||||
const connections = await db
|
||||
.select({
|
||||
@@ -196,19 +352,36 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an OAuth token if needed based on credential information
|
||||
* Refreshes an OAuth token if needed based on credential information.
|
||||
* Also handles service account credentials by generating a JWT-based token.
|
||||
* @param credentialId The ID of the credential to check and potentially refresh
|
||||
* @param userId The user ID who owns the credential (for security verification)
|
||||
* @param requestId Request ID for log correlation
|
||||
* @param scopes Optional scopes for service account token generation
|
||||
* @returns The valid access token or null if refresh fails
|
||||
*/
|
||||
export async function refreshAccessTokenIfNeeded(
|
||||
credentialId: string,
|
||||
userId: string,
|
||||
requestId: string
|
||||
requestId: string,
|
||||
scopes?: string[],
|
||||
impersonateEmail?: string
|
||||
): Promise<string | null> {
|
||||
// Get the credential directly using the getCredential helper
|
||||
const credential = await getCredential(requestId, credentialId, userId)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
if (!scopes?.length) {
|
||||
throw new Error('Scopes are required for service account credentials')
|
||||
}
|
||||
logger.info(`[${requestId}] Using service account token for credential`)
|
||||
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
|
||||
}
|
||||
|
||||
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
|
||||
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
|
||||
|
||||
if (!credential) {
|
||||
return null
|
||||
|
||||
@@ -15,12 +15,12 @@ const {
|
||||
mockLimit,
|
||||
mockUpdate,
|
||||
mockSet,
|
||||
mockDelete,
|
||||
mockCreateSuccessResponse,
|
||||
mockCreateErrorResponse,
|
||||
mockEncryptSecret,
|
||||
mockCheckChatAccess,
|
||||
mockDeployWorkflow,
|
||||
mockPerformChatUndeploy,
|
||||
mockLogger,
|
||||
} = vi.hoisted(() => {
|
||||
const logger = {
|
||||
@@ -40,12 +40,12 @@ const {
|
||||
mockLimit: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockSet: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockCreateSuccessResponse: vi.fn(),
|
||||
mockCreateErrorResponse: vi.fn(),
|
||||
mockEncryptSecret: vi.fn(),
|
||||
mockCheckChatAccess: vi.fn(),
|
||||
mockDeployWorkflow: vi.fn(),
|
||||
mockPerformChatUndeploy: vi.fn(),
|
||||
mockLogger: logger,
|
||||
}
|
||||
})
|
||||
@@ -66,7 +66,6 @@ vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
@@ -88,6 +87,9 @@ vi.mock('@/app/api/chat/utils', () => ({
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
deployWorkflow: mockDeployWorkflow,
|
||||
}))
|
||||
vi.mock('@/lib/workflows/orchestration', () => ({
|
||||
performChatUndeploy: mockPerformChatUndeploy,
|
||||
}))
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
@@ -106,7 +108,7 @@ describe('Chat Edit API Route', () => {
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
mockUpdate.mockReturnValue({ set: mockSet })
|
||||
mockSet.mockReturnValue({ where: mockWhere })
|
||||
mockDelete.mockReturnValue({ where: mockWhere })
|
||||
mockPerformChatUndeploy.mockResolvedValue({ success: true })
|
||||
|
||||
mockCreateSuccessResponse.mockImplementation((data) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
@@ -428,7 +430,11 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
|
||||
chatId: 'chat-123',
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
const data = await response.json()
|
||||
expect(data.message).toBe('Chat deployment deleted successfully')
|
||||
})
|
||||
@@ -451,7 +457,11 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
|
||||
chatId: 'chat-123',
|
||||
userId: 'admin-user-id',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getEmailDomain } from '@/lib/core/utils/urls'
|
||||
import { performChatUndeploy } from '@/lib/workflows/orchestration'
|
||||
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import { checkChatAccess } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
@@ -270,33 +271,25 @@ export async function DELETE(
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const {
|
||||
hasAccess,
|
||||
chat: chatRecord,
|
||||
workspaceId: chatWorkspaceId,
|
||||
} = await checkChatAccess(chatId, session.user.id)
|
||||
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
|
||||
chatId,
|
||||
session.user.id
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
await db.delete(chat).where(eq(chat.id, chatId))
|
||||
|
||||
logger.info(`Chat "${chatId}" deleted successfully`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: chatWorkspaceId || null,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.CHAT_DELETED,
|
||||
resourceType: AuditResourceType.CHAT,
|
||||
resourceId: chatId,
|
||||
resourceName: chatRecord?.title || chatId,
|
||||
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
|
||||
request: _request,
|
||||
const result = await performChatUndeploy({
|
||||
chatId,
|
||||
userId: session.user.id,
|
||||
workspaceId: chatWorkspaceId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to delete chat', 500)
|
||||
}
|
||||
|
||||
return createSuccessResponse({
|
||||
message: 'Chat deployment deleted successfully',
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createEnvMock } from '@sim/testing'
|
||||
import { createEnvMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -12,66 +12,51 @@ const {
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockLimit,
|
||||
mockInsert,
|
||||
mockValues,
|
||||
mockReturning,
|
||||
mockCreateSuccessResponse,
|
||||
mockCreateErrorResponse,
|
||||
mockEncryptSecret,
|
||||
mockCheckWorkflowAccessForChatCreation,
|
||||
mockDeployWorkflow,
|
||||
mockPerformChatDeploy,
|
||||
mockGetSession,
|
||||
mockUuidV4,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockLimit: vi.fn(),
|
||||
mockInsert: vi.fn(),
|
||||
mockValues: vi.fn(),
|
||||
mockReturning: vi.fn(),
|
||||
mockCreateSuccessResponse: vi.fn(),
|
||||
mockCreateErrorResponse: vi.fn(),
|
||||
mockEncryptSecret: vi.fn(),
|
||||
mockCheckWorkflowAccessForChatCreation: vi.fn(),
|
||||
mockDeployWorkflow: vi.fn(),
|
||||
mockPerformChatDeploy: vi.fn(),
|
||||
mockGetSession: vi.fn(),
|
||||
mockUuidV4: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/audit/log', () => auditMock)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
insert: mockInsert,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
chat: { userId: 'userId', identifier: 'identifier' },
|
||||
chat: { userId: 'userId', identifier: 'identifier', archivedAt: 'archivedAt' },
|
||||
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/workflows/utils', () => ({
|
||||
createSuccessResponse: mockCreateSuccessResponse,
|
||||
createErrorResponse: mockCreateErrorResponse,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
encryptSecret: mockEncryptSecret,
|
||||
}))
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: mockUuidV4,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/chat/utils', () => ({
|
||||
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
deployWorkflow: mockDeployWorkflow,
|
||||
vi.mock('@/lib/workflows/orchestration', () => ({
|
||||
performChatDeploy: mockPerformChatDeploy,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
@@ -94,10 +79,6 @@ describe('Chat API Route', () => {
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
mockInsert.mockReturnValue({ values: mockValues })
|
||||
mockValues.mockReturnValue({ returning: mockReturning })
|
||||
|
||||
mockUuidV4.mockReturnValue('test-uuid')
|
||||
|
||||
mockCreateSuccessResponse.mockImplementation((data) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
@@ -113,12 +94,10 @@ describe('Chat API Route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
|
||||
|
||||
mockDeployWorkflow.mockResolvedValue({
|
||||
mockPerformChatDeploy.mockResolvedValue({
|
||||
success: true,
|
||||
version: 1,
|
||||
deployedAt: new Date(),
|
||||
chatId: 'test-uuid',
|
||||
chatUrl: 'http://localhost:3000/chat/test-chat',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -277,7 +256,6 @@ describe('Chat API Route', () => {
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
|
||||
})
|
||||
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -287,6 +265,13 @@ describe('Chat API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-id',
|
||||
identifier: 'test-chat',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow chat deployment when user has workspace admin permission', async () => {
|
||||
@@ -309,7 +294,6 @@ describe('Chat API Route', () => {
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
|
||||
})
|
||||
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -319,6 +303,12 @@ describe('Chat API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-123',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
|
||||
@@ -383,7 +373,7 @@ describe('Chat API Route', () => {
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should auto-deploy workflow if not already deployed', async () => {
|
||||
it('should call performChatDeploy for undeployed workflow', async () => {
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-id', email: 'user@example.com' },
|
||||
})
|
||||
@@ -403,7 +393,6 @@ describe('Chat API Route', () => {
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
|
||||
})
|
||||
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -412,10 +401,12 @@ describe('Chat API Route', () => {
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDeployWorkflow).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow-123',
|
||||
deployedBy: 'user-id',
|
||||
})
|
||||
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-id',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,9 @@ import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import { performChatDeploy } from '@/lib/workflows/orchestration'
|
||||
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -109,7 +104,6 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check identifier availability and workflow access in parallel
|
||||
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -127,121 +121,27 @@ export async function POST(request: NextRequest) {
|
||||
return createErrorResponse('Workflow not found or access denied', 404)
|
||||
}
|
||||
|
||||
// Always deploy/redeploy the workflow to ensure latest version
|
||||
const result = await deployWorkflow({
|
||||
workflowId,
|
||||
deployedBy: session.user.id,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to deploy workflow', 500)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})`
|
||||
)
|
||||
|
||||
// Encrypt password if provided
|
||||
let encryptedPassword = null
|
||||
if (authType === 'password' && password) {
|
||||
const { encrypted } = await encryptSecret(password)
|
||||
encryptedPassword = encrypted
|
||||
}
|
||||
|
||||
// Create the chat deployment
|
||||
const id = uuidv4()
|
||||
|
||||
// Log the values we're inserting
|
||||
logger.info('Creating chat deployment with values:', {
|
||||
workflowId,
|
||||
identifier,
|
||||
title,
|
||||
authType,
|
||||
hasPassword: !!encryptedPassword,
|
||||
emailCount: allowedEmails?.length || 0,
|
||||
outputConfigsCount: outputConfigs.length,
|
||||
})
|
||||
|
||||
// Merge customizations with the additional fields
|
||||
const mergedCustomizations = {
|
||||
...(customizations || {}),
|
||||
primaryColor: customizations?.primaryColor || 'var(--brand-hover)',
|
||||
welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?',
|
||||
}
|
||||
|
||||
await db.insert(chat).values({
|
||||
id,
|
||||
const result = await performChatDeploy({
|
||||
workflowId,
|
||||
userId: session.user.id,
|
||||
identifier,
|
||||
title,
|
||||
description: description || null,
|
||||
customizations: mergedCustomizations,
|
||||
isActive: true,
|
||||
description,
|
||||
customizations,
|
||||
authType,
|
||||
password: encryptedPassword,
|
||||
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
|
||||
password,
|
||||
allowedEmails,
|
||||
outputConfigs,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
workspaceId: workflowRecord.workspaceId,
|
||||
})
|
||||
|
||||
// Return successful response with chat URL
|
||||
// Generate chat URL using path-based routing instead of subdomains
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
let chatUrl: string
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
let host = url.host
|
||||
if (host.startsWith('www.')) {
|
||||
host = host.substring(4)
|
||||
}
|
||||
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
|
||||
baseUrl,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
// Fallback based on environment
|
||||
if (isDev) {
|
||||
chatUrl = `http://localhost:3000/chat/${identifier}`
|
||||
} else {
|
||||
chatUrl = `https://sim.ai/chat/${identifier}`
|
||||
}
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to deploy chat', 500)
|
||||
}
|
||||
|
||||
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
|
||||
|
||||
try {
|
||||
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
||||
PlatformEvents.chatDeployed({
|
||||
chatId: id,
|
||||
workflowId,
|
||||
authType,
|
||||
hasOutputConfigs: outputConfigs.length > 0,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: workflowRecord.workspaceId || null,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.CHAT_DEPLOYED,
|
||||
resourceType: AuditResourceType.CHAT,
|
||||
resourceId: id,
|
||||
resourceName: title,
|
||||
description: `Deployed chat "${title}"`,
|
||||
metadata: { workflowId, identifier, authType },
|
||||
request,
|
||||
})
|
||||
|
||||
return createSuccessResponse({
|
||||
id,
|
||||
chatUrl,
|
||||
id: result.chatId,
|
||||
chatUrl: result.chatUrl,
|
||||
message: 'Chat deployment created successfully',
|
||||
})
|
||||
} catch (validationError) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
|
||||
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
requestChatTitle,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import { appendCopilotLogContext } from '@/lib/copilot/logging'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
|
||||
@@ -183,36 +183,31 @@ export async function POST(req: NextRequest) {
|
||||
const wf = await getWorkflowById(workflowId)
|
||||
resolvedWorkspaceId = wf?.workspaceId ?? undefined
|
||||
} catch {
|
||||
logger.warn(
|
||||
appendCopilotLogContext('Failed to resolve workspaceId from workflow', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageId,
|
||||
})
|
||||
)
|
||||
logger
|
||||
.withMetadata({ requestId: tracker.requestId, messageId: userMessageId })
|
||||
.warn('Failed to resolve workspaceId from workflow')
|
||||
}
|
||||
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
const reqLogger = logger.withMetadata({
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
})
|
||||
try {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Received chat POST', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
workflowId,
|
||||
hasContexts: Array.isArray(normalizedContexts),
|
||||
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
|
||||
contextsPreview: Array.isArray(normalizedContexts)
|
||||
? normalizedContexts.map((c: any) => ({
|
||||
kind: c?.kind,
|
||||
chatId: c?.chatId,
|
||||
workflowId: c?.workflowId,
|
||||
executionId: (c as any)?.executionId,
|
||||
label: c?.label,
|
||||
}))
|
||||
: undefined,
|
||||
}
|
||||
)
|
||||
reqLogger.info('Received chat POST', {
|
||||
workflowId,
|
||||
hasContexts: Array.isArray(normalizedContexts),
|
||||
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
|
||||
contextsPreview: Array.isArray(normalizedContexts)
|
||||
? normalizedContexts.map((c: any) => ({
|
||||
kind: c?.kind,
|
||||
chatId: c?.chatId,
|
||||
workflowId: c?.workflowId,
|
||||
executionId: (c as any)?.executionId,
|
||||
label: c?.label,
|
||||
}))
|
||||
: undefined,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
let currentChat: any = null
|
||||
@@ -250,40 +245,22 @@ export async function POST(req: NextRequest) {
|
||||
actualChatId
|
||||
)
|
||||
agentContexts = processed
|
||||
logger.error(
|
||||
appendCopilotLogContext('Contexts processed for request', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
processedCount: agentContexts.length,
|
||||
kinds: agentContexts.map((c) => c.type),
|
||||
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
|
||||
}
|
||||
)
|
||||
reqLogger.info('Contexts processed for request', {
|
||||
processedCount: agentContexts.length,
|
||||
kinds: agentContexts.map((c) => c.type),
|
||||
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
|
||||
})
|
||||
if (
|
||||
Array.isArray(normalizedContexts) &&
|
||||
normalizedContexts.length > 0 &&
|
||||
agentContexts.length === 0
|
||||
) {
|
||||
logger.warn(
|
||||
appendCopilotLogContext(
|
||||
'Contexts provided but none processed. Check executionId for logs contexts.',
|
||||
{
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}
|
||||
)
|
||||
reqLogger.warn(
|
||||
'Contexts provided but none processed. Check executionId for logs contexts.'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to process contexts', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
e
|
||||
)
|
||||
reqLogger.error('Failed to process contexts', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,13 +289,7 @@ export async function POST(req: NextRequest) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
agentContexts.push(result.value)
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to resolve resource attachment', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
result.reason
|
||||
)
|
||||
reqLogger.error('Failed to resolve resource attachment', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,26 +328,20 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
|
||||
try {
|
||||
logger.error(
|
||||
appendCopilotLogContext('About to call Sim Agent', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
hasContext: agentContexts.length > 0,
|
||||
contextCount: agentContexts.length,
|
||||
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
|
||||
messageLength: message.length,
|
||||
mode: effectiveMode,
|
||||
hasTools: Array.isArray(requestPayload.tools),
|
||||
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
|
||||
hasBaseTools: Array.isArray(requestPayload.baseTools),
|
||||
baseToolCount: Array.isArray(requestPayload.baseTools)
|
||||
? requestPayload.baseTools.length
|
||||
: 0,
|
||||
hasCredentials: !!requestPayload.credentials,
|
||||
}
|
||||
)
|
||||
reqLogger.info('About to call Sim Agent', {
|
||||
hasContext: agentContexts.length > 0,
|
||||
contextCount: agentContexts.length,
|
||||
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
|
||||
messageLength: message.length,
|
||||
mode: effectiveMode,
|
||||
hasTools: Array.isArray(requestPayload.tools),
|
||||
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
|
||||
hasBaseTools: Array.isArray(requestPayload.baseTools),
|
||||
baseToolCount: Array.isArray(requestPayload.baseTools)
|
||||
? requestPayload.baseTools.length
|
||||
: 0,
|
||||
hasCredentials: !!requestPayload.credentials,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
if (stream && actualChatId) {
|
||||
@@ -520,16 +485,10 @@ export async function POST(req: NextRequest) {
|
||||
.where(eq(copilotChats.id, actualChatId))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to persist chat messages', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
chatId: actualChatId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
)
|
||||
reqLogger.error('Failed to persist chat messages', {
|
||||
chatId: actualChatId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -539,10 +498,26 @@ export async function POST(req: NextRequest) {
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
|
||||
const nsExecutionId = crypto.randomUUID()
|
||||
const nsRunId = crypto.randomUUID()
|
||||
|
||||
if (actualChatId) {
|
||||
await createRunSegment({
|
||||
id: nsRunId,
|
||||
executionId: nsExecutionId,
|
||||
chatId: actualChatId,
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
streamId: userMessageIdToUse,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
executionId: nsExecutionId,
|
||||
runId: nsRunId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
@@ -555,19 +530,13 @@ export async function POST(req: NextRequest) {
|
||||
provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined,
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext('Non-streaming response from orchestrator', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
hasContent: !!responseData.content,
|
||||
contentLength: responseData.content?.length || 0,
|
||||
model: responseData.model,
|
||||
provider: responseData.provider,
|
||||
toolCallsCount: responseData.toolCalls?.length || 0,
|
||||
}
|
||||
)
|
||||
reqLogger.info('Non-streaming response from orchestrator', {
|
||||
hasContent: !!responseData.content,
|
||||
contentLength: responseData.content?.length || 0,
|
||||
model: responseData.model,
|
||||
provider: responseData.provider,
|
||||
toolCallsCount: responseData.toolCalls?.length || 0,
|
||||
})
|
||||
|
||||
// Save messages if we have a chat
|
||||
if (currentChat && responseData.content) {
|
||||
@@ -600,12 +569,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Start title generation in parallel if this is first message (non-streaming)
|
||||
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Starting title generation for non-streaming response', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
})
|
||||
)
|
||||
reqLogger.info('Starting title generation for non-streaming response')
|
||||
requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
@@ -616,22 +580,11 @@ export async function POST(req: NextRequest) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
logger.error(
|
||||
appendCopilotLogContext(`Generated and saved title: ${title}`, {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
})
|
||||
)
|
||||
reqLogger.info(`Generated and saved title: ${title}`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Title generation failed', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
error
|
||||
)
|
||||
reqLogger.error('Title generation failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -645,17 +598,11 @@ export async function POST(req: NextRequest) {
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext('Returning non-streaming response', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
duration: tracker.getDuration(),
|
||||
chatId: actualChatId,
|
||||
responseLength: responseData.content?.length || 0,
|
||||
}
|
||||
)
|
||||
reqLogger.info('Returning non-streaming response', {
|
||||
duration: tracker.getDuration(),
|
||||
chatId: actualChatId,
|
||||
responseLength: responseData.content?.length || 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -679,33 +626,25 @@ export async function POST(req: NextRequest) {
|
||||
const duration = tracker.getDuration()
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Validation error', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: pendingChatStreamID ?? undefined,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
|
||||
.error('Validation error', {
|
||||
duration,
|
||||
errors: error.errors,
|
||||
}
|
||||
)
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext('Error handling copilot chat', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: pendingChatStreamID ?? undefined,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
|
||||
.error('Error handling copilot chat', {
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
@@ -750,16 +689,13 @@ export async function GET(req: NextRequest) {
|
||||
status: meta?.status || 'unknown',
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
appendCopilotLogContext('Failed to read stream snapshot for chat', {
|
||||
messageId: chat.conversationId || undefined,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ messageId: chat.conversationId || undefined })
|
||||
.warn('Failed to read stream snapshot for chat', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,11 +714,9 @@ export async function GET(req: NextRequest) {
|
||||
...(streamSnapshot ? { streamSnapshot } : {}),
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext(`Retrieved chat ${chatId}`, {
|
||||
messageId: chat.conversationId || undefined,
|
||||
})
|
||||
)
|
||||
logger
|
||||
.withMetadata({ messageId: chat.conversationId || undefined })
|
||||
.info(`Retrieved chat ${chatId}`)
|
||||
return NextResponse.json({ success: true, chat: transformedChat })
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user