Compare commits

..

24 Commits

Author SHA1 Message Date
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Waleed
46ba315701 feat(tools): added more intercom tools (#3022) 2026-01-26 21:41:44 -08:00
Waleed
077e702dd8 improvement(tools): updated kalshi and polymarket tools and blocks (#3021) 2026-01-26 21:01:33 -08:00
Waleed
d8df08d3d3 improvement(mcp): remove mcp-remote for cursor config (#3020) 2026-01-26 19:54:27 -08:00
Vikhyath Mondreti
51891daf9a feat(code): undo-redo state (#3018)
* feat(code): undo-redo state

* address greptile

* address bugbot comments

* fix debounce flush

* inc debounce time

* fix wand case

* address comments
2026-01-26 19:40:40 -08:00
Vikhyath Mondreti
9ee5dfe185 improvement(workflow): hide raw json childworkflow span (#3019) 2026-01-26 18:47:35 -08:00
Emir Karabeg
9cba8eee48 improvement(preview): error paths, loops, workflow (#3010)
* improvement(switch): dark styling

* improvement(settings): change deployed MCPs to MCPs servers

* improvement(preview): added error paths, loop logic

* improvement(preview): nested workflows preview

* feat(preview): lightweight param

* improvement(preview): staging changes integrated
2026-01-26 17:57:48 -08:00
Siddharth Ganesan
cb650132c7 fix(executor): fix. convergent error edges (#3015) 2026-01-26 17:25:09 -08:00
Waleed
9dbf56f9cd feat(note-block): expand media embed support with tuned aspect ratios (#3016)
* feat(note-block): expand media embed support with tuned aspect ratios

* fix(note-block): add artist parameter to Bandcamp embed URLs

Include the artist subdomain in Bandcamp track and album embed URLs
to ensure proper embed resolution.

* fix(note-block): add required src attribute to track elements

HTML spec requires track elements to have a src attribute.

* fix(note-block): address embed URL matching issues

- Fix YouTube regex to handle v= anywhere in query params
- Fix Twitch channel match to exclude /clip/ URLs
- Remove Mux support (HLS not supported in most browsers)
- Remove Bandcamp support (requires numeric IDs, not slugs)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 17:22:44 -08:00
Vikhyath Mondreti
5189473e06 fix(max-tokens): anthropic models streaming vs non-streaming (#2999)
* fix(max-tokens): anthropic models streaming vs non-streaming

* default max inc

* update the docs
2026-01-26 17:05:33 -08:00
Vikhyath Mondreti
37900988eb fix(kb): workspace id required for creation (#3001)
* fix(kb): workspace id required for creation

* fix tests
2026-01-26 16:27:07 -08:00
Vikhyath Mondreti
3cc9b1ae56 fix(input-format): resolution for blocks with input format fields (#3012)
* fix input format

* fix tests

* address bugbot comment
2026-01-26 16:04:19 -08:00
Waleed
3ccbee187d improvement(docs): updated logo, added lightbox to action media, fixed minor styling inconsistencies between themes (#3014)
* improvement(docs): updated logo, added lightbox to action media, fixed minor styling inconsistencies between themes

* updated og image

* ack comments
2026-01-26 15:49:23 -08:00
Waleed
36945deaa5 improvement(preview): consolidate block rendering and fix handle configurations (#3013)
* improvement(preview): consolidate block rendering and fix handle configurations

* refactor(preview): extract SubflowContainerProps interface
2026-01-26 14:56:06 -08:00
Waleed
ebf2852733 fix(copilot): reliable zoom to changed blocks after diff applied (#3011) 2026-01-26 13:54:01 -08:00
Waleed
12495ef89c feat(ci): auto-create github releases and add workflow permissions (#3009) 2026-01-26 13:28:59 -08:00
Waleed
d8d85fccf0 feat(helm): add branding configmap for custom assets (#3008) 2026-01-26 13:19:23 -08:00
Waleed
56bc809c6f fix(docs): separate local and blob asset resolution for quick-reference (#3007)
* fix(docs): separate local and blob asset resolution for quick-reference

ActionImage now uses local paths directly for PNGs while ActionVideo
uses blob storage with proper path normalization (strips static/ prefix).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(docs): simplify asset resolution by using correct paths directly

Remove path normalization logic from action-media component. Instead,
use the appropriate paths in MDX:
- PNGs: /static/quick-reference/... (local)
- MP4s: quick-reference/... (blob via getAssetUrl)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:07:11 -08:00
Vikhyath Mondreti
c7bd48573a fix(codegen): function prologue resolution edge cases (#3005)
* fix(codegen): function prologue resolution edge cases

* remove hacky fallback

* case insensitive lookup

* fix python nan and inf resolution

* remove template literal check

* fix tests

* consolidate literal gen
2026-01-26 10:16:13 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
80f00479a3 improvement(docs): added images and videos to quick references (#3004)
* improvement(docs): added images and videos to quick references

* moved mp4s to blob, completed quick reference guide
2026-01-25 23:31:40 -08:00
Vikhyath Mondreti
c140e90559 fix(multi-trigger): resolution paths for triggers (#3002)
* fix(multi-trigger): resolution paths for triggers

* fix trigger input format version

* fix output condition logic

* update type guard:

* fix
2026-01-25 23:20:42 -08:00
Vikhyath Mondreti
d83c418111 fix(supabase): storage upload + add basic mode version (#2996)
* fix(supabase): storage upload + add basic mode version

* fix subblock update

* remove redundant check in a2a

* add check consistently for baseline diff
2026-01-25 14:19:30 -08:00
Waleed
be2a9ef0f8 fix(storage): support Azure connection string for presigned URLs (#2997)
* fix(docs): update requirements to be more accurate for deploying the app

* updated kb to support 1536 dimension vectors for models other than text embedding 3 small

* fix(storage): support Azure connection string for presigned URLs

* fix(kb): update test for embedding dimensions parameter

* fix(storage): align credential source ordering for consistency
2026-01-25 13:06:12 -08:00
239 changed files with 13153 additions and 3590 deletions

View File

@@ -44,7 +44,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 4G memory: 1G
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio - DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio

View File

@@ -10,6 +10,9 @@ concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
permissions:
contents: read
jobs: jobs:
test-build: test-build:
name: Test and Build name: Test and Build
@@ -278,3 +281,30 @@ jobs:
if: needs.check-docs-changes.outputs.docs_changed == 'true' if: needs.check-docs-changes.outputs.docs_changed == 'true'
uses: ./.github/workflows/docs-embeddings.yml uses: ./.github/workflows/docs-embeddings.yml
secrets: inherit secrets: inherit
# Create GitHub Release (only for version commits on main, after all builds complete)
create-release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [create-ghcr-manifests, detect-version]
if: needs.detect-version.outputs.is_release == 'true'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Create release
env:
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }}

View File

@@ -4,6 +4,9 @@ on:
workflow_call: workflow_call:
workflow_dispatch: # Allow manual triggering workflow_dispatch: # Allow manual triggering
permissions:
contents: read
jobs: jobs:
process-docs-embeddings: process-docs-embeddings:
name: Process Documentation Embeddings name: Process Documentation Embeddings

View File

@@ -4,6 +4,9 @@ on:
workflow_call: workflow_call:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
migrate: migrate:
name: Apply Database Migrations name: Apply Database Migrations

View File

@@ -6,6 +6,9 @@ on:
paths: paths:
- 'packages/cli/**' - 'packages/cli/**'
permissions:
contents: read
jobs: jobs:
publish-npm: publish-npm:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -6,6 +6,9 @@ on:
paths: paths:
- 'packages/python-sdk/**' - 'packages/python-sdk/**'
permissions:
contents: write
jobs: jobs:
publish-pypi: publish-pypi:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -6,6 +6,9 @@ on:
paths: paths:
- 'packages/ts-sdk/**' - 'packages/ts-sdk/**'
permissions:
contents: write
jobs: jobs:
publish-npm: publish-npm:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -4,6 +4,9 @@ on:
workflow_call: workflow_call:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
test-build: test-build:
name: Test and Build name: Test and Build

View File

@@ -185,11 +185,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
tableOfContent={{ tableOfContent={{
style: 'clerk', style: 'clerk',
enabled: true, enabled: true,
header: (
<div key='toc-header' className='mb-2 font-medium text-sm'>
On this page
</div>
),
footer: <TOCFooter />, footer: <TOCFooter />,
single: false, single: false,
}} }}

View File

@@ -3,13 +3,13 @@ import { defineI18nUI } from 'fumadocs-ui/i18n'
import { DocsLayout } from 'fumadocs-ui/layouts/docs' import { DocsLayout } from 'fumadocs-ui/layouts/docs'
import { RootProvider } from 'fumadocs-ui/provider/next' import { RootProvider } from 'fumadocs-ui/provider/next'
import { Geist_Mono, Inter } from 'next/font/google' import { Geist_Mono, Inter } from 'next/font/google'
import Image from 'next/image'
import { import {
SidebarFolder, SidebarFolder,
SidebarItem, SidebarItem,
SidebarSeparator, SidebarSeparator,
} from '@/components/docs-layout/sidebar-components' } from '@/components/docs-layout/sidebar-components'
import { Navbar } from '@/components/navbar/navbar' import { Navbar } from '@/components/navbar/navbar'
import { SimLogoFull } from '@/components/ui/sim-logo'
import { i18n } from '@/lib/i18n' import { i18n } from '@/lib/i18n'
import { source } from '@/lib/source' import { source } from '@/lib/source'
import '../global.css' import '../global.css'
@@ -102,16 +102,7 @@ export default async function Layout({ children, params }: LayoutProps) {
<DocsLayout <DocsLayout
tree={source.pageTree[lang]} tree={source.pageTree[lang]}
nav={{ nav={{
title: ( title: <SimLogoFull className='h-7 w-auto' />,
<Image
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
priority
/>
),
}} }}
sidebar={{ sidebar={{
defaultOpenLevel: 0, defaultOpenLevel: 0,

View File

@@ -33,15 +33,41 @@ async function loadGoogleFont(font: string, weights: string, text: string): Prom
throw new Error('Failed to load font data') throw new Error('Failed to load font data')
} }
/**
* Sim logo with icon and "Sim" text for OG image.
*/
function SimLogoFull() {
return (
<svg height='28' viewBox='720 440 1020 320' fill='none'>
{/* Green icon - top left shape with cutout */}
<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'
/>
{/* Green icon - bottom right square */}
<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'
/>
{/* "Sim" text - white for dark background */}
<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>
)
}
/** /**
* Generates dynamic Open Graph images for documentation pages. * Generates dynamic Open Graph images for documentation pages.
* Style matches Cursor docs: dark background, title at top, logo bottom-left, domain bottom-right.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const title = searchParams.get('title') || 'Documentation' const title = searchParams.get('title') || 'Documentation'
const baseUrl = new URL(request.url).origin
const allText = `${title}docs.sim.ai` const allText = `${title}docs.sim.ai`
const fontData = await loadGoogleFont('Geist', '400;500;600', allText) const fontData = await loadGoogleFont('Geist', '400;500;600', allText)
@@ -52,84 +78,39 @@ export async function GET(request: NextRequest) {
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
background: '#0c0c0c', justifyContent: 'space-between',
position: 'relative', padding: '56px 64px',
background: '#121212', // Dark mode background matching docs (hsla 0, 0%, 7%)
fontFamily: 'Geist', fontFamily: 'Geist',
}} }}
> >
{/* Base gradient layer - subtle purple tint across the entire image */} {/* Title at top */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'radial-gradient(ellipse 150% 100% at 50% 100%, rgba(88, 28, 135, 0.15) 0%, rgba(88, 28, 135, 0.08) 25%, rgba(88, 28, 135, 0.03) 50%, transparent 80%)',
display: 'flex',
}}
/>
{/* Secondary glow - adds depth without harsh edges */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'radial-gradient(ellipse 100% 80% at 80% 90%, rgba(112, 31, 252, 0.12) 0%, rgba(112, 31, 252, 0.04) 40%, transparent 70%)',
display: 'flex',
}}
/>
{/* Top darkening - creates natural vignette */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, transparent 40%, transparent 100%)',
display: 'flex',
}}
/>
{/* Content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '56px 72px',
height: '100%',
justifyContent: 'space-between',
}}
>
{/* Logo */}
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
{/* Title */}
<span <span
style={{ style={{
fontSize: getTitleFontSize(title), fontSize: getTitleFontSize(title),
fontWeight: 600, fontWeight: 500,
color: '#ffffff', color: '#fafafa', // Light text matching docs
lineHeight: 1.1, lineHeight: 1.2,
letterSpacing: '-0.02em', letterSpacing: '-0.02em',
}} }}
> >
{title} {title}
</span> </span>
{/* Footer */} {/* Footer: icon left, domain right */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<SimLogoFull />
<span <span
style={{ style={{
fontSize: 20, fontSize: 20,
fontWeight: 500, fontWeight: 400,
color: '#71717a', color: '#71717a',
}} }}
> >

View File

@@ -9,11 +9,20 @@ body {
} }
@theme { @theme {
--color-fd-primary: #802fff; /* Purple from control-bar component */ --color-fd-primary: #33c482; /* Green from Sim logo */
--font-geist-sans: var(--font-geist-sans); --font-geist-sans: var(--font-geist-sans);
--font-geist-mono: var(--font-geist-mono); --font-geist-mono: var(--font-geist-mono);
} }
/* Ensure primary color is set in both light and dark modes */
:root {
--color-fd-primary: #33c482;
}
.dark {
--color-fd-primary: #33c482;
}
/* Font family utilities */ /* Font family utilities */
.font-sans { .font-sans {
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
@@ -34,7 +43,7 @@ body {
:root { :root {
--fd-border: transparent !important; --fd-border: transparent !important;
--fd-border-sidebar: transparent !important; --fd-border-sidebar: transparent !important;
--fd-nav-height: 64px; /* Custom navbar height (h-16 = 4rem = 64px) */ --fd-nav-height: 65px; /* Custom navbar height (h-16 = 64px + 1px border) */
/* Content container width used to center main content */ /* Content container width used to center main content */
--spacing-fd-container: 1400px; --spacing-fd-container: 1400px;
/* Edge gutter = leftover space on each side of centered container */ /* Edge gutter = leftover space on each side of centered container */
@@ -136,11 +145,11 @@ aside#nd-sidebar {
/* On mobile, let fumadocs handle the layout natively */ /* On mobile, let fumadocs handle the layout natively */
@media (min-width: 1024px) { @media (min-width: 1024px) {
:root { :root {
--fd-banner-height: 64px !important; --fd-banner-height: 65px !important; /* 64px navbar + 1px border */
} }
#nd-docs-layout { #nd-docs-layout {
--fd-docs-height: calc(100dvh - 64px) !important; --fd-docs-height: calc(100dvh - 65px) !important; /* 64px navbar + 1px border */
--fd-sidebar-width: 300px !important; --fd-sidebar-width: 300px !important;
margin-left: var(--sidebar-offset) !important; margin-left: var(--sidebar-offset) !important;
margin-right: var(--toc-offset) !important; margin-right: var(--toc-offset) !important;
@@ -227,19 +236,19 @@ html:not(.dark) #nd-sidebar button:not([aria-label*="ollapse"]):not([aria-label*
letter-spacing: 0.05em !important; letter-spacing: 0.05em !important;
} }
/* Override active state (NO PURPLE) */ /* Override active state */
#nd-sidebar a[data-active="true"], #nd-sidebar a[data-active="true"],
#nd-sidebar button[data-active="true"], #nd-sidebar button[data-active="true"],
#nd-sidebar a.bg-fd-primary\/10, #nd-sidebar a.bg-fd-primary\/10,
#nd-sidebar a.text-fd-primary, #nd-sidebar a.text-fd-primary,
#nd-sidebar a[class*="bg-fd-primary"], #nd-sidebar a[class*="bg-fd-primary"],
#nd-sidebar a[class*="text-fd-primary"], #nd-sidebar a[class*="text-fd-primary"],
/* Override custom sidebar purple classes */ /* Override custom sidebar green classes */
#nd-sidebar #nd-sidebar
a.bg-purple-50\/80, a.bg-emerald-50\/80,
#nd-sidebar a.text-purple-600, #nd-sidebar a.text-emerald-600,
#nd-sidebar a[class*="bg-purple"], #nd-sidebar a[class*="bg-emerald"],
#nd-sidebar a[class*="text-purple"] { #nd-sidebar a[class*="text-emerald"] {
background-image: none !important; background-image: none !important;
} }
@@ -250,10 +259,10 @@ html.dark #nd-sidebar a.bg-fd-primary\/10,
html.dark #nd-sidebar a.text-fd-primary, html.dark #nd-sidebar a.text-fd-primary,
html.dark #nd-sidebar a[class*="bg-fd-primary"], html.dark #nd-sidebar a[class*="bg-fd-primary"],
html.dark #nd-sidebar a[class*="text-fd-primary"], html.dark #nd-sidebar a[class*="text-fd-primary"],
html.dark #nd-sidebar a.bg-purple-50\/80, html.dark #nd-sidebar a.bg-emerald-50\/80,
html.dark #nd-sidebar a.text-purple-600, html.dark #nd-sidebar a.text-emerald-600,
html.dark #nd-sidebar a[class*="bg-purple"], html.dark #nd-sidebar a[class*="bg-emerald"],
html.dark #nd-sidebar a[class*="text-purple"] { html.dark #nd-sidebar a[class*="text-emerald"] {
background-color: rgba(255, 255, 255, 0.15) !important; background-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 1) !important; color: rgba(255, 255, 255, 1) !important;
} }
@@ -265,10 +274,10 @@ html:not(.dark) #nd-sidebar a.bg-fd-primary\/10,
html:not(.dark) #nd-sidebar a.text-fd-primary, html:not(.dark) #nd-sidebar a.text-fd-primary,
html:not(.dark) #nd-sidebar a[class*="bg-fd-primary"], html:not(.dark) #nd-sidebar a[class*="bg-fd-primary"],
html:not(.dark) #nd-sidebar a[class*="text-fd-primary"], html:not(.dark) #nd-sidebar a[class*="text-fd-primary"],
html:not(.dark) #nd-sidebar a.bg-purple-50\/80, html:not(.dark) #nd-sidebar a.bg-emerald-50\/80,
html:not(.dark) #nd-sidebar a.text-purple-600, html:not(.dark) #nd-sidebar a.text-emerald-600,
html:not(.dark) #nd-sidebar a[class*="bg-purple"], html:not(.dark) #nd-sidebar a[class*="bg-emerald"],
html:not(.dark) #nd-sidebar a[class*="text-purple"] { html:not(.dark) #nd-sidebar a[class*="text-emerald"] {
background-color: rgba(0, 0, 0, 0.07) !important; background-color: rgba(0, 0, 0, 0.07) !important;
color: rgba(0, 0, 0, 0.9) !important; color: rgba(0, 0, 0, 0.9) !important;
} }
@@ -286,8 +295,8 @@ html:not(.dark) #nd-sidebar button:hover:not([data-active="true"]) {
} }
/* Dark mode - ensure active/selected items don't change on hover */ /* Dark mode - ensure active/selected items don't change on hover */
html.dark #nd-sidebar a.bg-purple-50\/80:hover, html.dark #nd-sidebar a.bg-emerald-50\/80:hover,
html.dark #nd-sidebar a[class*="bg-purple"]:hover, html.dark #nd-sidebar a[class*="bg-emerald"]:hover,
html.dark #nd-sidebar a[data-active="true"]:hover, html.dark #nd-sidebar a[data-active="true"]:hover,
html.dark #nd-sidebar button[data-active="true"]:hover { html.dark #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(255, 255, 255, 0.15) !important; background-color: rgba(255, 255, 255, 0.15) !important;
@@ -295,8 +304,8 @@ html.dark #nd-sidebar button[data-active="true"]:hover {
} }
/* Light mode - ensure active/selected items don't change on hover */ /* Light mode - ensure active/selected items don't change on hover */
html:not(.dark) #nd-sidebar a.bg-purple-50\/80:hover, html:not(.dark) #nd-sidebar a.bg-emerald-50\/80:hover,
html:not(.dark) #nd-sidebar a[class*="bg-purple"]:hover, html:not(.dark) #nd-sidebar a[class*="bg-emerald"]:hover,
html:not(.dark) #nd-sidebar a[data-active="true"]:hover, html:not(.dark) #nd-sidebar a[data-active="true"]:hover,
html:not(.dark) #nd-sidebar button[data-active="true"]:hover { html:not(.dark) #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(0, 0, 0, 0.07) !important; background-color: rgba(0, 0, 0, 0.07) !important;
@@ -368,16 +377,24 @@ aside[data-sidebar] > *:not([data-sidebar-viewport]) {
button[aria-label="Toggle Sidebar"], button[aria-label="Toggle Sidebar"],
button[aria-label="Collapse Sidebar"], button[aria-label="Collapse Sidebar"],
/* Hide nav title/logo in sidebar on desktop - target all possible locations */ /* Hide nav title/logo in sidebar on desktop - target all possible locations */
/* Lower specificity selectors first (attribute selectors) */
[data-sidebar-header],
[data-sidebar] [data-title],
aside[data-sidebar] a[href="/"], aside[data-sidebar] a[href="/"],
aside[data-sidebar] a[href="/"] img, aside[data-sidebar] a[href="/"] img,
aside[data-sidebar] > a:first-child, aside[data-sidebar] > a:first-child,
aside[data-sidebar] > div > a:first-child, aside[data-sidebar] > div > a:first-child,
aside[data-sidebar] img[alt="Sim"], aside[data-sidebar] img[alt="Sim"],
[data-sidebar-header], aside[data-sidebar] svg[aria-label="Sim"],
[data-sidebar] [data-title], /* Higher specificity selectors (ID selectors) */
#nd-sidebar
a[href="/"],
#nd-sidebar a[href="/"] img,
#nd-sidebar a[href="/"] svg,
#nd-sidebar > a:first-child, #nd-sidebar > a:first-child,
#nd-sidebar > div:first-child > a:first-child, #nd-sidebar > div:first-child > a:first-child,
#nd-sidebar img[alt="Sim"], #nd-sidebar img[alt="Sim"],
#nd-sidebar svg[aria-label="Sim"],
/* Hide theme toggle at bottom of sidebar on desktop */ /* Hide theme toggle at bottom of sidebar on desktop */
#nd-sidebar #nd-sidebar
> footer, > footer,
@@ -515,6 +532,15 @@ pre code .line {
color: var(--color-fd-primary); color: var(--color-fd-primary);
} }
/* ============================================
TOC (Table of Contents) Styling
============================================ */
/* Remove the thin border-left on nested TOC items (keeps main indicator only) */
#nd-toc a[style*="padding-inline-start"] {
border-left: none !important;
}
/* Add bottom spacing to prevent abrupt page endings */ /* Add bottom spacing to prevent abrupt page endings */
[data-content] { [data-content] {
padding-top: 1.5rem !important; padding-top: 1.5rem !important;

View File

@@ -44,7 +44,7 @@ export function SidebarItem({ item }: { item: Item }) {
'lg:text-gray-600 lg:dark:text-gray-400', 'lg:text-gray-600 lg:dark:text-gray-400',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40', !active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active && active &&
'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400' 'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
)} )}
> >
{item.name} {item.name}
@@ -79,7 +79,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
'lg:text-gray-600 lg:dark:text-gray-400', 'lg:text-gray-600 lg:dark:text-gray-400',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40', !active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active && active &&
'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400' 'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
)} )}
> >
{item.name} {item.name}
@@ -104,7 +104,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
'lg:text-gray-800 lg:dark:text-gray-200', 'lg:text-gray-800 lg:dark:text-gray-200',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40', !active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active && active &&
'lg:bg-purple-50/80 lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400' 'lg:bg-emerald-50/80 lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
)} )}
> >
{item.name} {item.name}

View File

@@ -23,7 +23,7 @@ export function TOCFooter() {
rel='noopener noreferrer' rel='noopener noreferrer'
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] px-3 pr-[10px] pl-[12px] font-medium text-sm text-white shadow-[inset_0_2px_4px_0_#9B77FF] outline-none transition-all hover:shadow-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50' className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[10px] border border-[#2AAD6C] bg-gradient-to-b from-[#3ED990] to-[#2AAD6C] px-3 pr-[10px] pl-[12px] font-medium text-sm text-white shadow-[inset_0_2px_4px_0_#5EE8A8] outline-none transition-all hover:shadow-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
aria-label='Get started with Sim - Sign up for free' aria-label='Get started with Sim - Sign up for free'
> >
<span>Get started</span> <span>Get started</span>

View File

@@ -1,9 +1,9 @@
'use client' 'use client'
import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { LanguageDropdown } from '@/components/ui/language-dropdown' import { LanguageDropdown } from '@/components/ui/language-dropdown'
import { SearchTrigger } from '@/components/ui/search-trigger' import { SearchTrigger } from '@/components/ui/search-trigger'
import { SimLogoFull } from '@/components/ui/sim-logo'
import { ThemeToggle } from '@/components/ui/theme-toggle' import { ThemeToggle } from '@/components/ui/theme-toggle'
export function Navbar() { export function Navbar() {
@@ -27,13 +27,7 @@ export function Navbar() {
{/* Left cluster: logo */} {/* Left cluster: logo */}
<div className='flex items-center'> <div className='flex items-center'>
<Link href='/' className='flex min-w-[100px] items-center'> <Link href='/' className='flex min-w-[100px] items-center'>
<Image <SimLogoFull className='h-7 w-auto' />
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
/>
</Link> </Link>
</div> </div>

View File

@@ -0,0 +1,87 @@
'use client'
import { useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
interface ActionImageProps {
src: string
alt: string
enableLightbox?: boolean
}
interface ActionVideoProps {
src: string
alt: string
enableLightbox?: boolean
}
export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const handleClick = () => {
if (enableLightbox) {
setIsLightboxOpen(true)
}
}
return (
<>
<img
src={src}
alt={alt}
onClick={handleClick}
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
)}
/>
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='image'
/>
)}
</>
)
}
export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const resolvedSrc = getAssetUrl(src)
const handleClick = () => {
if (enableLightbox) {
setIsLightboxOpen(true)
}
}
return (
<>
<video
src={resolvedSrc}
autoPlay
loop
muted
playsInline
onClick={handleClick}
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
)}
/>
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='video'
/>
)}
</>
)
}

View File

@@ -101,7 +101,6 @@ import {
ShopifyIcon, ShopifyIcon,
SlackIcon, SlackIcon,
SmtpIcon, SmtpIcon,
SpotifyIcon,
SQSIcon, SQSIcon,
SshIcon, SshIcon,
STTIcon, STTIcon,
@@ -182,7 +181,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
jina: JinaAIIcon, jina: JinaAIIcon,
jira: JiraIcon, jira: JiraIcon,
jira_service_management: JiraServiceManagementIcon, jira_service_management: JiraServiceManagementIcon,
kalshi: KalshiIcon, kalshi_v2: KalshiIcon,
knowledge: PackageSearchIcon, knowledge: PackageSearchIcon,
langsmith: LangsmithIcon, langsmith: LangsmithIcon,
lemlist: LemlistIcon, lemlist: LemlistIcon,
@@ -229,7 +228,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
shopify: ShopifyIcon, shopify: ShopifyIcon,
slack: SlackIcon, slack: SlackIcon,
smtp: SmtpIcon, smtp: SmtpIcon,
spotify: SpotifyIcon,
sqs: SQSIcon, sqs: SQSIcon,
ssh: SshIcon, ssh: SshIcon,
stagehand: StagehandIcon, stagehand: StagehandIcon,

View File

@@ -1,8 +1,9 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Check, ChevronRight } from 'lucide-react' import { Check, ChevronDown } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation' import { useParams, usePathname, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
const languages = { const languages = {
en: { name: 'English', flag: '🇺🇸' }, en: { name: 'English', flag: '🇺🇸' },
@@ -15,6 +16,7 @@ const languages = {
export function LanguageDropdown() { export function LanguageDropdown() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [hoveredIndex, setHoveredIndex] = useState<number>(-1)
const pathname = usePathname() const pathname = usePathname()
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
@@ -71,6 +73,15 @@ export function LanguageDropdown() {
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [isOpen]) }, [isOpen])
// Reset hovered index when popover closes
useEffect(() => {
if (!isOpen) {
setHoveredIndex(-1)
}
}, [isOpen])
const languageEntries = Object.entries(languages)
return ( return (
<div className='relative'> <div className='relative'>
<button <button
@@ -82,14 +93,14 @@ export function LanguageDropdown() {
aria-haspopup='listbox' aria-haspopup='listbox'
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls='language-menu' aria-controls='language-menu'
className='flex cursor-pointer items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring' className='flex cursor-pointer items-center gap-1.5 rounded-[6px] px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
style={{ style={{
fontFamily: fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}} }}
> >
<span>{languages[currentLang as keyof typeof languages]?.name}</span> <span>{languages[currentLang as keyof typeof languages]?.name}</span>
<ChevronRight className='h-3.5 w-3.5' /> <ChevronDown className={cn('h-3.5 w-3.5 transition-transform', isOpen && 'rotate-180')} />
</button> </button>
{isOpen && ( {isOpen && (
@@ -98,9 +109,13 @@ export function LanguageDropdown() {
<div <div
id='language-menu' id='language-menu'
role='listbox' role='listbox'
className='absolute top-full right-0 z-[1001] mt-1 max-h-[75vh] w-56 overflow-auto rounded-xl border border-border/50 bg-white shadow-2xl md:w-44 md:bg-background/95 md:backdrop-blur-md dark:bg-neutral-950 md:dark:bg-background/95' className='absolute top-full right-0 z-[1001] mt-2 max-h-[400px] min-w-[160px] overflow-auto rounded-[6px] bg-white px-[6px] py-[6px] shadow-lg dark:bg-neutral-900'
> >
{Object.entries(languages).map(([code, lang]) => ( {languageEntries.map(([code, lang], index) => {
const isSelected = currentLang === code
const isHovered = hoveredIndex === index
return (
<button <button
key={code} key={code}
onClick={(e) => { onClick={(e) => {
@@ -108,19 +123,23 @@ export function LanguageDropdown() {
e.stopPropagation() e.stopPropagation()
handleLanguageChange(code) handleLanguageChange(code)
}} }}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
role='option' role='option'
aria-selected={currentLang === code} aria-selected={isSelected}
className={`flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${ className={cn(
currentLang === code ? 'bg-muted/60 font-medium text-primary' : 'text-foreground' 'flex h-[26px] w-full min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] text-[13px] transition-colors',
}`} 'text-neutral-700 dark:text-neutral-200',
> isHovered && 'bg-neutral-100 dark:bg-neutral-800',
<span className='text-base md:text-sm'>{lang.flag}</span> 'focus:outline-none'
<span className='leading-none'>{lang.name}</span>
{currentLang === code && (
<Check className='ml-auto h-4 w-4 text-primary md:h-3.5 md:w-3.5' />
)} )}
>
<span className='text-[13px]'>{lang.flag}</span>
<span className='flex-1 text-left leading-none'>{lang.name}</span>
{isSelected && <Check className='ml-auto h-3.5 w-3.5' />}
</button> </button>
))} )
})}
</div> </div>
</> </>
)} )}

View File

@@ -0,0 +1,108 @@
'use client'
import { cn } from '@/lib/utils'
interface SimLogoProps {
className?: string
}
/**
* Sim logo with icon and text.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogo({ className }: SimLogoProps) {
return (
<svg
viewBox='720 440 320 320'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('h-7 w-auto', className)}
aria-label='Sim'
>
{/* Green icon - top left shape with cutout */}
<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'
/>
{/* Green icon - bottom right square */}
<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'
/>
{/* Gradient overlay on bottom right square */}
<path
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
fill='url(#sim-logo-gradient)'
fillOpacity='0.2'
/>
<defs>
<linearGradient
id='sim-logo-gradient'
x1='904.414'
y1='624.199'
x2='978.836'
y2='698.447'
gradientUnits='userSpaceOnUse'
>
<stop />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
)
}
/**
* Full Sim logo with icon and "Sim" text.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogoFull({ className }: SimLogoProps) {
return (
<svg
viewBox='720 440 1020 320'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('h-7 w-auto', className)}
aria-label='Sim'
>
{/* Green icon - top left shape with cutout */}
<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'
/>
{/* Green icon - bottom right square */}
<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'
/>
{/* Gradient overlay on bottom right square */}
<path
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
fill='url(#sim-logo-full-gradient)'
fillOpacity='0.2'
/>
{/* "Sim" text - adapts to light/dark mode via currentColor */}
<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'
className='fill-neutral-900 dark:fill-white'
/>
<defs>
<linearGradient
id='sim-logo-full-gradient'
x1='904.414'
y1='624.199'
x2='978.836'
y2='698.447'
gradientUnits='userSpaceOnUse'
>
<stop />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
)
}

View File

@@ -17,7 +17,7 @@ MCP-Server gruppieren Ihre Workflow-Tools zusammen. Erstellen und verwalten Sie
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs** 1. Navigieren Sie zu **Einstellungen → MCP-Server**
2. Klicken Sie auf **Server erstellen** 2. Klicken Sie auf **Server erstellen**
3. Geben Sie einen Namen und eine optionale Beschreibung ein 3. Geben Sie einen Namen und eine optionale Beschreibung ein
4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients 4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients
@@ -79,7 +79,7 @@ Füge deinen API-Key-Header (`X-API-Key`) für authentifizierten Zugriff hinzu,
## Server-Verwaltung ## Server-Verwaltung
In der Server-Detailansicht unter **Einstellungen → Bereitgestellte MCPs** können Sie: In der Server-Detailansicht unter **Einstellungen → MCP-Server** können Sie:
- **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden - **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden
- **URL kopieren**: Die Server-URL für MCP-Clients abrufen - **URL kopieren**: Die Server-URL für MCP-Clients abrufen

View File

@@ -27,7 +27,7 @@ MCP-Server stellen Sammlungen von Tools bereit, die Ihre Agenten nutzen können.
</div> </div>
1. Navigieren Sie zu Ihren Workspace-Einstellungen 1. Navigieren Sie zu Ihren Workspace-Einstellungen
2. Gehen Sie zum Abschnitt **Bereitgestellte MCPs** 2. Gehen Sie zum Abschnitt **MCP-Server**
3. Klicken Sie auf **MCP-Server hinzufügen** 3. Klicken Sie auf **MCP-Server hinzufügen**
4. Geben Sie die Server-Konfigurationsdetails ein 4. Geben Sie die Server-Konfigurationsdetails ein
5. Speichern Sie die Konfiguration 5. Speichern Sie die Konfiguration

View File

@@ -10,12 +10,20 @@ Stellen Sie Sim auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes berei
## Anforderungen ## Anforderungen
| Ressource | Minimum | Empfohlen | | Ressource | Klein | Standard | Produktion |
|----------|---------|-------------| |----------|-------|----------|------------|
| CPU | 2 Kerne | 4+ Kerne | | CPU | 2 Kerne | 4 Kerne | 8+ Kerne |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| Speicher | 20 GB SSD | 50+ GB SSD | | Speicher | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | Neueste Version | | Docker | 20.10+ | 20.10+ | Neueste Version |
**Klein**: Entwicklung, Tests, Einzelnutzer (1-5 Nutzer)
**Standard**: Teams (5-50 Nutzer), moderate Arbeitslasten
**Produktion**: Große Teams (50+ Nutzer), Hochverfügbarkeit, intensive Workflow-Ausführung
<Callout type="info">
Die Ressourcenanforderungen werden durch Workflow-Ausführung (isolated-vm Sandboxing), Dateiverarbeitung (In-Memory-Dokumentenparsing) und Vektoroperationen (pgvector) bestimmt. Arbeitsspeicher ist typischerweise der limitierende Faktor, nicht CPU. Produktionsdaten zeigen, dass die Hauptanwendung durchschnittlich 4-8 GB und bei hoher Last bis zu 12 GB benötigt.
</Callout>
## Schnellstart ## Schnellstart

View File

@@ -56,6 +56,10 @@ Controls response randomness and creativity:
- **Medium (0.3-0.7)**: Balanced creativity and focus. Good for general use. - **Medium (0.3-0.7)**: Balanced creativity and focus. Good for general use.
- **High (0.7-2.0)**: Creative and varied. Ideal for brainstorming and content generation. - **High (0.7-2.0)**: Creative and varied. Ideal for brainstorming and content generation.
### Max Output Tokens
Controls the maximum length of the model's response. For Anthropic models, Sim uses reliable defaults: streaming executions use the model's full capacity (e.g. 64,000 tokens for Claude 4.5), while non-streaming executions default to 8,192 to avoid timeout issues. For long-form content generation via API, explicitly set a higher value.
### API Key ### API Key
Your API key for the selected LLM provider. This is securely stored and used for authentication. Your API key for the selected LLM provider. This is securely stored and used for authentication.

View File

@@ -16,7 +16,7 @@ MCP servers group your workflow tools together. Create and manage them in worksp
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Navigate to **Settings → Deployed MCPs** 1. Navigate to **Settings → MCP Servers**
2. Click **Create Server** 2. Click **Create Server**
3. Enter a name and optional description 3. Enter a name and optional description
4. Copy the server URL for use in your MCP clients 4. Copy the server URL for use in your MCP clients
@@ -78,7 +78,7 @@ Include your API key header (`X-API-Key`) for authenticated access when using mc
## Server Management ## Server Management
From the server detail view in **Settings → Deployed MCPs**, you can: From the server detail view in **Settings → MCP Servers**, you can:
- **View tools**: See all workflows added to a server - **View tools**: See all workflows added to a server
- **Copy URL**: Get the server URL for MCP clients - **Copy URL**: Get the server URL for MCP clients

View File

@@ -27,7 +27,7 @@ MCP servers provide collections of tools that your agents can use. Configure the
</div> </div>
1. Navigate to your workspace settings 1. Navigate to your workspace settings
2. Go to the **Deployed MCPs** section 2. Go to the **MCP Servers** section
3. Click **Add MCP Server** 3. Click **Add MCP Server**
4. Enter the server configuration details 4. Enter the server configuration details
5. Save the configuration 5. Save the configuration

View File

@@ -4,6 +4,7 @@ description: Essential actions for navigating and using the Sim workflow editor
--- ---
import { Callout } from 'fumadocs-ui/components/callout' import { Callout } from 'fumadocs-ui/components/callout'
import { ActionImage, ActionVideo } from '@/components/ui/action-media'
A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts). A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts).
@@ -13,124 +14,362 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
## Workspaces ## Workspaces
| Action | How | <table>
|--------|-----| <thead>
| Create a workspace | Click workspace dropdown in sidebar → **New Workspace** | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Rename a workspace | Workspace settings → Edit name | </thead>
| Switch workspaces | Click workspace dropdown in sidebar → Select workspace | <tbody>
| Invite team members | Workspace settings → **Team** → **Invite** | <tr>
<td>Create a workspace</td>
<td>Click workspace dropdown → **New Workspace**</td>
<td><ActionVideo src="quick-reference/create-workspace.mp4" alt="Create workspace" /></td>
</tr>
<tr>
<td>Switch workspaces</td>
<td>Click workspace dropdown → Select workspace</td>
<td><ActionVideo src="quick-reference/switch-workspace.mp4" alt="Switch workspaces" /></td>
</tr>
<tr>
<td>Invite team members</td>
<td>Sidebar → **Invite**</td>
<td><ActionVideo src="quick-reference/invite.mp4" alt="Invite team members" /></td>
</tr>
<tr>
<td>Rename a workspace</td>
<td>Right-click workspace → **Rename**</td>
<td rowSpan={4}><ActionImage src="/static/quick-reference/workspace-context-menu.png" alt="Workspace context menu" /></td>
</tr>
<tr>
<td>Duplicate a workspace</td>
<td>Right-click workspace → **Duplicate**</td>
</tr>
<tr>
<td>Export a workspace</td>
<td>Right-click workspace → **Export**</td>
</tr>
<tr>
<td>Delete a workspace</td>
<td>Right-click workspace → **Delete**</td>
</tr>
</tbody>
</table>
## Workflows ## Workflows
| Action | How | <table>
|--------|-----| <thead>
| Create a workflow | Click **New Workflow** button or `Mod+Shift+A` | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Rename a workflow | Double-click workflow name in sidebar, or right-click → **Rename** | </thead>
| Duplicate a workflow | Right-click workflow → **Duplicate** | <tbody>
| Reorder workflows | Drag workflow up/down in the sidebar list | <tr>
| Import a workflow | Sidebar menu → **Import** → Select file | <td>Create a workflow</td>
| Create a folder | Right-click in sidebar → **New Folder** | <td>Click **+** button in sidebar</td>
| Rename a folder | Right-click folder → **Rename** | <td><ActionImage src="/static/quick-reference/create-workflow.png" alt="Create workflow" /></td>
| Delete a folder | Right-click folder → **Delete** | </tr>
| Collapse/expand folder | Click folder arrow, or double-click folder | <tr>
| Move workflow to folder | Drag workflow onto folder in sidebar | <td>Reorder / move workflows</td>
| Delete a workflow | Right-click workflow → **Delete** | <td>Drag workflow up/down or onto a folder</td>
| Export a workflow | Right-click workflow → **Export** | <td><ActionVideo src="quick-reference/reordering.mp4" alt="Reorder workflows" /></td>
| Assign workflow color | Right-click workflow → **Change Color** | </tr>
| Multi-select workflows | `Mod+Click` or `Shift+Click` workflows in sidebar | <tr>
| Open in new tab | Right-click workflow → **Open in New Tab** | <td>Import a workflow</td>
<td>Click import button in sidebar → Select file</td>
<td><ActionImage src="/static/quick-reference/import-workflow.png" alt="Import workflow" /></td>
</tr>
<tr>
<td>Multi-select workflows</td>
<td>`Mod+Click` or `Shift+Click` workflows in sidebar</td>
<td><ActionVideo src="quick-reference/multiselect.mp4" alt="Multi-select workflows" /></td>
</tr>
<tr>
<td>Open in new tab</td>
<td>Right-click workflow → **Open in New Tab**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/workflow-context-menu.png" alt="Workflow context menu" /></td>
</tr>
<tr>
<td>Rename a workflow</td>
<td>Right-click workflow → **Rename**</td>
</tr>
<tr>
<td>Assign workflow color</td>
<td>Right-click workflow → **Change Color**</td>
</tr>
<tr>
<td>Duplicate a workflow</td>
<td>Right-click workflow → **Duplicate**</td>
</tr>
<tr>
<td>Export a workflow</td>
<td>Right-click workflow → **Export**</td>
</tr>
<tr>
<td>Delete a workflow</td>
<td>Right-click workflow → **Delete**</td>
</tr>
<tr>
<td>Rename a folder</td>
<td>Right-click folder → **Rename**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/folder-context-menu.png" alt="Folder context menu" /></td>
</tr>
<tr>
<td>Create workflow in folder</td>
<td>Right-click folder → **Create workflow**</td>
</tr>
<tr>
<td>Create folder in folder</td>
<td>Right-click folder → **Create folder**</td>
</tr>
<tr>
<td>Duplicate a folder</td>
<td>Right-click folder → **Duplicate**</td>
</tr>
<tr>
<td>Export a folder</td>
<td>Right-click folder → **Export**</td>
</tr>
<tr>
<td>Delete a folder</td>
<td>Right-click folder → **Delete**</td>
</tr>
</tbody>
</table>
## Blocks ## Blocks
| Action | How | <table>
|--------|-----| <thead>
| Add a block | Drag from Toolbar panel, or right-click canvas → **Add Block** | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Select a block | Click on the block | </thead>
| Multi-select blocks | `Mod+Click` additional blocks, or right-drag to draw selection box | <tbody>
| Move blocks | Drag selected block(s) to new position | <tr>
| Copy blocks | `Mod+C` with blocks selected | <td>Add a block</td>
| Paste blocks | `Mod+V` to paste copied blocks | <td>Drag from Toolbar panel, or right-click canvas → **Add Block**</td>
| Duplicate blocks | Right-click → **Duplicate** | <td><ActionVideo src="quick-reference/add-block.mp4" alt="Add a block" /></td>
| Delete blocks | `Delete` or `Backspace` key, or right-click → **Delete** | </tr>
| Rename a block | Click block name in header, or edit in the Editor panel | <tr>
| Enable/Disable a block | Right-click → **Enable/Disable** | <td>Multi-select blocks</td>
| Toggle handle orientation | Right-click → **Toggle Handles** | <td>`Mod+Click` additional blocks, or shift-drag to draw selection box</td>
| Toggle trigger mode | Right-click trigger block → **Toggle Trigger Mode** | <td><ActionVideo src="quick-reference/multiselect-blocks.mp4" alt="Multi-select blocks" /></td>
| Configure a block | Select block → use Editor panel on right | </tr>
<tr>
<td>Copy blocks</td>
<td>`Mod+C` with blocks selected</td>
<td rowSpan={2}><ActionVideo src="quick-reference/copy-paste.mp4" alt="Copy and paste blocks" /></td>
</tr>
<tr>
<td>Paste blocks</td>
<td>`Mod+V` to paste copied blocks</td>
</tr>
<tr>
<td>Duplicate blocks</td>
<td>Right-click → **Duplicate**</td>
<td><ActionVideo src="quick-reference/duplicate-block.mp4" alt="Duplicate blocks" /></td>
</tr>
<tr>
<td>Delete blocks</td>
<td>`Delete` or `Backspace` key, or right-click → **Delete**</td>
<td><ActionImage src="/static/quick-reference/delete-block.png" alt="Delete block" /></td>
</tr>
<tr>
<td>Rename a block</td>
<td>Click block name in header, or edit in the Editor panel</td>
<td><ActionVideo src="quick-reference/rename-block.mp4" alt="Rename a block" /></td>
</tr>
<tr>
<td>Enable/Disable a block</td>
<td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr>
<tr>
<td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td>
<td><ActionVideo src="quick-reference/toggle-handles.mp4" alt="Toggle handle orientation" /></td>
</tr>
<tr>
<td>Configure a block</td>
<td>Select block → use Editor panel on right</td>
<td><ActionVideo src="quick-reference/configure-block.mp4" alt="Configure a block" /></td>
</tr>
</tbody>
</table>
## Connections ## Connections
| Action | How | <table>
|--------|-----| <thead>
| Create a connection | Drag from output handle to input handle | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Delete a connection | Click edge to select → `Delete` key | </thead>
| Use output in another block | Drag connection tag into input field | <tbody>
<tr>
## Canvas Navigation <td>Create a connection</td>
<td>Drag from output handle to input handle</td>
| Action | How | <td><ActionVideo src="quick-reference/connect-blocks.mp4" alt="Connect blocks" /></td>
|--------|-----| </tr>
| Pan/move canvas | Left-drag on empty space, or scroll/trackpad | <tr>
| Zoom in/out | Scroll wheel or pinch gesture | <td>Delete a connection</td>
| Auto-layout | `Shift+L` | <td>Click edge to select `Delete` key</td>
| Draw selection box | Right-drag on empty canvas area | <td><ActionVideo src="quick-reference/delete-connection.mp4" alt="Delete connection" /></td>
</tr>
<tr>
<td>Use output in another block</td>
<td>Drag connection tag into input field</td>
<td><ActionVideo src="quick-reference/connection-tag.mp4" alt="Use connection tag" /></td>
</tr>
</tbody>
</table>
## Panels & Views ## Panels & Views
| Action | How | <table>
|--------|-----| <thead>
| Open Copilot tab | Press `C` or click Copilot tab | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Open Toolbar tab | Press `T` or click Toolbar tab | </thead>
| Open Editor tab | Press `E` or click Editor tab | <tbody>
| Search toolbar | `Mod+F` | <tr>
| Toggle advanced mode | Click toggle button on input fields | <td>Search toolbar</td>
| Resize panels | Drag panel edge | <td>`Mod+F`</td>
| Collapse/expand sidebar | Click collapse button on sidebar | <td><ActionVideo src="quick-reference/search-toolbar.mp4" alt="Search toolbar" /></td>
</tr>
<tr>
<td>Search everything</td>
<td>`Mod+K`</td>
<td><ActionImage src="/static/quick-reference/search-everything.png" alt="Search everything" /></td>
</tr>
<tr>
<td>Toggle manual mode</td>
<td>Click toggle button to switch between manual and selector</td>
<td><ActionImage src="/static/quick-reference/toggle-manual-mode.png" alt="Toggle manual mode" /></td>
</tr>
<tr>
<td>Collapse/expand sidebar</td>
<td>Click collapse button on sidebar</td>
<td><ActionVideo src="quick-reference/collapse-sidebar.mp4" alt="Collapse sidebar" /></td>
</tr>
</tbody>
</table>
## Running & Testing ## Running & Testing
| Action | How | <table>
|--------|-----| <thead>
| Run workflow | Click Play button or `Mod+Enter` | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Stop workflow | Click Stop button or `Mod+Enter` while running | </thead>
| Test with chat | Use Chat panel on the right side | <tbody>
| Select output to view | Click dropdown in Chat panel → Select block output | <tr>
| Clear chat history | Click clear button in Chat panel | <td>Run workflow</td>
| View execution logs | Open terminal panel at bottom, or `Mod+L` | <td>Click Run Workflow button or `Mod+Enter`</td>
| Filter logs by block | Click block filter in terminal | <td><ActionImage src="/static/quick-reference/run-workflow.png" alt="Run workflow" /></td>
| Filter logs by status | Click status filter in terminal | </tr>
| Search logs | Use search field in terminal | <tr>
| Copy log entry | Right-click log entry → **Copy** | <td>Stop workflow</td>
| Clear terminal | `Mod+D` | <td>Click Stop button or `Mod+Enter` while running</td>
<td><ActionImage src="/static/quick-reference/stop-workflow.png" alt="Stop workflow" /></td>
</tr>
<tr>
<td>Test with chat</td>
<td>Use Chat panel on the right side</td>
<td><ActionImage src="/static/quick-reference/test-chat.png" alt="Test with chat" /></td>
</tr>
<tr>
<td>Select output to view</td>
<td>Click dropdown in Chat panel → Select block output</td>
<td><ActionImage src="/static/quick-reference/output-select.png" alt="Select output to view" /></td>
</tr>
<tr>
<td>Clear chat history</td>
<td>Click clear button in Chat panel</td>
<td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td>
</tr>
<tr>
<td>View execution logs</td>
<td>Open terminal panel at bottom, or `Mod+L`</td>
<td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td>
</tr>
<tr>
<td>Filter logs by block or status</td>
<td>Click block filter in terminal or right-click log entry → **Filter by Block** or **Filter by Status**</td>
<td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td>
</tr>
<tr>
<td>Search logs</td>
<td>Use search field in terminal or right-click log entry → **Search**</td>
<td><ActionImage src="/static/quick-reference/terminal-search.png" alt="Search logs" /></td>
</tr>
<tr>
<td>Copy log entry</td>
<td>Clipboard Icon or Right-click log entry → **Copy**</td>
<td><ActionImage src="/static/quick-reference/copy-log.png" alt="Copy log entry" /></td>
</tr>
<tr>
<td>Clear terminal</td>
<td>Trash icon or `Mod+D`</td>
<td><ActionImage src="/static/quick-reference/clear-terminal.png" alt="Clear terminal" /></td>
</tr>
</tbody>
</table>
## Deployment ## Deployment
| Action | How | <table>
|--------|-----| <thead>
| Deploy a workflow | Click **Deploy** button in Deploy tab | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Update deployment | Click **Update** when changes are detected | </thead>
| View deployment status | Check status indicator (Live/Update/Deploy) in Deploy tab | <tbody>
| Revert deployment | Access previous versions in Deploy tab | <tr>
| Copy webhook URL | Deploy tab → Copy webhook URL | <td>Deploy a workflow</td>
| Copy API endpoint | Deploy tab → Copy API endpoint URL | <td>Click **Deploy** button in panel</td>
| Set up a schedule | Add Schedule trigger block → Configure interval | <td><ActionImage src="/static/quick-reference/deploy.png" alt="Deploy workflow" /></td>
</tr>
<tr>
<td>Update deployment</td>
<td>Click **Update** when changes are detected</td>
<td><ActionImage src="/static/quick-reference/update-deployment.png" alt="Update deployment" /></td>
</tr>
<tr>
<td>View deployment status</td>
<td>Check status indicator (Live/Update/Deploy) in Deploy tab</td>
<td><ActionImage src="/static/quick-reference/view-deployment.png" alt="View deployment status" /></td>
</tr>
<tr>
<td>Revert deployment</td>
<td>Access previous versions in Deploy tab → **Promote to live**</td>
<td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td>
</tr>
<tr>
<td>Copy API endpoint</td>
<td>Deploy tab → API → Copy API cURL</td>
<td><ActionImage src="/static/quick-reference/copy-api.png" alt="Copy API endpoint" /></td>
</tr>
</tbody>
</table>
## Variables ## Variables
| Action | How | <table>
|--------|-----| <thead>
| Add workflow variable | Variables tab → **Add Variable** | <tr><th>Action</th><th>How</th><th>Preview</th></tr>
| Edit workflow variable | Variables tab → Click variable to edit | </thead>
| Delete workflow variable | Variables tab → Click delete icon on variable | <tbody>
| Add environment variable | Settings → **Environment Variables** → **Add** | <tr>
| Reference a variable | Use `{{variableName}}` syntax in block inputs | <td>Add / Edit / Delete workflow variable</td>
<td>Panel -> Variables -> **Add Variable**, click to edit, or delete icon</td>
## Credentials <td><ActionImage src="/static/quick-reference/variables.png" alt="Variables panel" /></td>
</tr>
| Action | How | <tr>
|--------|-----| <td>Add environment variable</td>
| Add API key | Block credential field → **Add Credential** → Enter API key | <td>Settings → **Environment Variables** → **Add**</td>
| Connect OAuth account | Block credential field → **Connect** → Authorize with provider | <td><ActionImage src="/static/quick-reference/add-env-variable.png" alt="Add environment variable" /></td>
| Manage credentials | Settings → **Credentials** | </tr>
| Remove credential | Settings → **Credentials** → Delete credential | <tr>
<td>Reference a workflow variable</td>
<td>Use `<blockName.itemName>` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/variable-reference.png" alt="Reference workflow variable" /></td>
</tr>
<tr>
<td>Reference an environment variable</td>
<td>Use `{{ENV_VAR}}` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/env-variable-reference.png" alt="Reference environment variable" /></td>
</tr>
</tbody>
</table>

View File

@@ -16,12 +16,20 @@ Deploy Sim on your own infrastructure with Docker or Kubernetes.
## Requirements ## Requirements
| Resource | Minimum | Recommended | | Resource | Small | Standard | Production |
|----------|---------|-------------| |----------|-------|----------|------------|
| CPU | 2 cores | 4+ cores | | CPU | 2 cores | 4 cores | 8+ cores |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| Storage | 20 GB SSD | 50+ GB SSD | | Storage | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | Latest | | Docker | 20.10+ | 20.10+ | Latest |
**Small**: Development, testing, single user (1-5 users)
**Standard**: Teams (5-50 users), moderate workloads
**Production**: Large teams (50+ users), high availability, heavy workflow execution
<Callout type="info">
Resource requirements are driven by workflow execution (isolated-vm sandboxing), file processing (in-memory document parsing), and vector operations (pgvector). Memory is typically the constraining factor rather than CPU. Production telemetry shows the main app uses 4-8 GB average with peaks up to 12 GB under heavy load.
</Callout>
## Quick Start ## Quick Start

View File

@@ -47,6 +47,7 @@ Runs a browser automation task using BrowserUse
| `save_browser_data` | boolean | No | Whether to save browser data | | `save_browser_data` | boolean | No | Whether to save browser data |
| `model` | string | No | LLM model to use \(default: gpt-4o\) | | `model` | string | No | LLM model to use \(default: gpt-4o\) |
| `apiKey` | string | Yes | API key for BrowserUse API | | `apiKey` | string | Yes | API key for BrowserUse API |
| `profile_id` | string | No | Browser profile ID for persistent sessions \(cookies, login state\) |
#### Output #### Output

View File

@@ -647,6 +647,42 @@ Retrieve a single ticket by ID from Intercom. Returns API-aligned fields only.
| `ticketId` | string | ID of the retrieved ticket | | `ticketId` | string | ID of the retrieved ticket |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `intercom_update_ticket`
Update a ticket in Intercom (change state, assignment, attributes)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `ticketId` | string | Yes | The ID of the ticket to update |
| `ticket_attributes` | string | No | JSON object with ticket attributes \(e.g., \{"_default_title_":"New Title","_default_description_":"Updated description"\}\) |
| `open` | boolean | No | Set to false to close the ticket, true to keep it open |
| `is_shared` | boolean | No | Whether the ticket is visible to users |
| `snoozed_until` | number | No | Unix timestamp for when the ticket should reopen |
| `admin_id` | string | No | The ID of the admin performing the update \(needed for workflows and attribution\) |
| `assignee_id` | string | No | The ID of the admin or team to assign the ticket to. Set to "0" to unassign. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ticket` | object | The updated ticket object |
| ↳ `id` | string | Unique identifier for the ticket |
| ↳ `type` | string | Object type \(ticket\) |
| ↳ `ticket_id` | string | Ticket ID shown in Intercom UI |
| ↳ `ticket_state` | string | State of the ticket |
| ↳ `ticket_attributes` | object | Attributes of the ticket |
| ↳ `open` | boolean | Whether the ticket is open |
| ↳ `is_shared` | boolean | Whether the ticket is visible to users |
| ↳ `snoozed_until` | number | Unix timestamp when ticket will reopen |
| ↳ `admin_assignee_id` | string | ID of assigned admin |
| ↳ `team_assignee_id` | string | ID of assigned team |
| ↳ `created_at` | number | Unix timestamp when ticket was created |
| ↳ `updated_at` | number | Unix timestamp when ticket was last updated |
| `ticketId` | string | ID of the updated ticket |
| `ticket_state` | string | Current state of the ticket |
### `intercom_create_message` ### `intercom_create_message`
Create and send a new admin-initiated message in Intercom. Returns API-aligned fields only. Create and send a new admin-initiated message in Intercom. Returns API-aligned fields only.
@@ -680,4 +716,340 @@ Create and send a new admin-initiated message in Intercom. Returns API-aligned f
| `messageId` | string | ID of the created message | | `messageId` | string | ID of the created message |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `intercom_list_admins`
Fetch a list of all admins for the workspace
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `admins` | array | Array of admin objects |
| ↳ `id` | string | Unique identifier for the admin |
| ↳ `type` | string | Object type \(admin\) |
| ↳ `name` | string | Name of the admin |
| ↳ `email` | string | Email of the admin |
| ↳ `job_title` | string | Job title of the admin |
| ↳ `away_mode_enabled` | boolean | Whether admin is in away mode |
| ↳ `away_mode_reassign` | boolean | Whether to reassign conversations when away |
| ↳ `has_inbox_seat` | boolean | Whether admin has a paid inbox seat |
| ↳ `team_ids` | array | List of team IDs the admin belongs to |
| ↳ `avatar` | object | Avatar information |
| ↳ `email_verified` | boolean | Whether email is verified |
| `type` | string | Object type \(admin.list\) |
### `intercom_close_conversation`
Close a conversation in Intercom
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `conversationId` | string | Yes | The ID of the conversation to close |
| `admin_id` | string | Yes | The ID of the admin performing the action |
| `body` | string | No | Optional closing message to add to the conversation |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `conversation` | object | The closed conversation object |
| ↳ `id` | string | Unique identifier for the conversation |
| ↳ `type` | string | Object type \(conversation\) |
| ↳ `state` | string | State of the conversation \(closed\) |
| ↳ `open` | boolean | Whether the conversation is open \(false\) |
| ↳ `read` | boolean | Whether the conversation has been read |
| ↳ `created_at` | number | Unix timestamp when conversation was created |
| ↳ `updated_at` | number | Unix timestamp when conversation was last updated |
| `conversationId` | string | ID of the closed conversation |
| `state` | string | State of the conversation \(closed\) |
### `intercom_open_conversation`
Open a closed or snoozed conversation in Intercom
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `conversationId` | string | Yes | The ID of the conversation to open |
| `admin_id` | string | Yes | The ID of the admin performing the action |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `conversation` | object | The opened conversation object |
| ↳ `id` | string | Unique identifier for the conversation |
| ↳ `type` | string | Object type \(conversation\) |
| ↳ `state` | string | State of the conversation \(open\) |
| ↳ `open` | boolean | Whether the conversation is open \(true\) |
| ↳ `read` | boolean | Whether the conversation has been read |
| ↳ `created_at` | number | Unix timestamp when conversation was created |
| ↳ `updated_at` | number | Unix timestamp when conversation was last updated |
| `conversationId` | string | ID of the opened conversation |
| `state` | string | State of the conversation \(open\) |
### `intercom_snooze_conversation`
Snooze a conversation to reopen at a future time
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `conversationId` | string | Yes | The ID of the conversation to snooze |
| `admin_id` | string | Yes | The ID of the admin performing the action |
| `snoozed_until` | number | Yes | Unix timestamp for when the conversation should reopen |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `conversation` | object | The snoozed conversation object |
| ↳ `id` | string | Unique identifier for the conversation |
| ↳ `type` | string | Object type \(conversation\) |
| ↳ `state` | string | State of the conversation \(snoozed\) |
| ↳ `open` | boolean | Whether the conversation is open |
| ↳ `snoozed_until` | number | Unix timestamp when conversation will reopen |
| ↳ `created_at` | number | Unix timestamp when conversation was created |
| ↳ `updated_at` | number | Unix timestamp when conversation was last updated |
| `conversationId` | string | ID of the snoozed conversation |
| `state` | string | State of the conversation \(snoozed\) |
| `snoozed_until` | number | Unix timestamp when conversation will reopen |
### `intercom_assign_conversation`
Assign a conversation to an admin or team in Intercom
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `conversationId` | string | Yes | The ID of the conversation to assign |
| `admin_id` | string | Yes | The ID of the admin performing the assignment |
| `assignee_id` | string | Yes | The ID of the admin or team to assign the conversation to. Set to "0" to unassign. |
| `body` | string | No | Optional message to add when assigning \(e.g., "Passing to the support team"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `conversation` | object | The assigned conversation object |
| ↳ `id` | string | Unique identifier for the conversation |
| ↳ `type` | string | Object type \(conversation\) |
| ↳ `state` | string | State of the conversation |
| ↳ `open` | boolean | Whether the conversation is open |
| ↳ `admin_assignee_id` | number | ID of the assigned admin |
| ↳ `team_assignee_id` | string | ID of the assigned team |
| ↳ `created_at` | number | Unix timestamp when conversation was created |
| ↳ `updated_at` | number | Unix timestamp when conversation was last updated |
| `conversationId` | string | ID of the assigned conversation |
| `admin_assignee_id` | number | ID of the assigned admin |
| `team_assignee_id` | string | ID of the assigned team |
### `intercom_list_tags`
Fetch a list of all tags in the workspace
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tags` | array | Array of tag objects |
| ↳ `id` | string | Unique identifier for the tag |
| ↳ `type` | string | Object type \(tag\) |
| ↳ `name` | string | Name of the tag |
| `type` | string | Object type \(list\) |
### `intercom_create_tag`
Create a new tag or update an existing tag name
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | Yes | The name of the tag. Will create a new tag if not found, or update the name if id is provided. |
| `id` | string | No | The ID of an existing tag to update. Omit to create a new tag. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique identifier for the tag |
| `name` | string | Name of the tag |
| `type` | string | Object type \(tag\) |
### `intercom_tag_contact`
Add a tag to a specific contact
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `contactId` | string | Yes | The ID of the contact to tag |
| `tagId` | string | Yes | The ID of the tag to apply |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique identifier for the tag |
| `name` | string | Name of the tag |
| `type` | string | Object type \(tag\) |
### `intercom_untag_contact`
Remove a tag from a specific contact
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `contactId` | string | Yes | The ID of the contact to untag |
| `tagId` | string | Yes | The ID of the tag to remove |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique identifier for the tag that was removed |
| `name` | string | Name of the tag that was removed |
| `type` | string | Object type \(tag\) |
### `intercom_tag_conversation`
Add a tag to a specific conversation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `conversationId` | string | Yes | The ID of the conversation to tag |
| `tagId` | string | Yes | The ID of the tag to apply |
| `admin_id` | string | Yes | The ID of the admin applying the tag |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique identifier for the tag |
| `name` | string | Name of the tag |
| `type` | string | Object type \(tag\) |
### `intercom_create_note`
Add a note to a specific contact
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `contactId` | string | Yes | The ID of the contact to add the note to |
| `body` | string | Yes | The text content of the note |
| `admin_id` | string | No | The ID of the admin creating the note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique identifier for the note |
| `body` | string | The text content of the note |
| `created_at` | number | Unix timestamp when the note was created |
| `type` | string | Object type \(note\) |
| `author` | object | The admin who created the note |
| ↳ `type` | string | Author type \(admin\) |
| ↳ `id` | string | Author ID |
| ↳ `name` | string | Author name |
| ↳ `email` | string | Author email |
| `contact` | object | The contact the note was created for |
| ↳ `type` | string | Contact type |
| ↳ `id` | string | Contact ID |
### `intercom_create_event`
Track a custom event for a contact in Intercom
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `event_name` | string | Yes | The name of the event \(e.g., "order-completed"\). Use past-tense verb-noun format for readability. |
| `created_at` | number | No | Unix timestamp for when the event occurred. Strongly recommended for uniqueness. |
| `user_id` | string | No | Your identifier for the user \(external_id\) |
| `email` | string | No | Email address of the user. Use only if your app uses email to uniquely identify users. |
| `id` | string | No | The Intercom contact ID |
| `metadata` | string | No | JSON object with up to 10 metadata key-value pairs about the event \(e.g., \{"order_value": 99.99\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accepted` | boolean | Whether the event was accepted \(202 Accepted\) |
### `intercom_attach_contact_to_company`
Attach a contact to a company in Intercom
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `contactId` | string | Yes | The ID of the contact to attach to the company |
| `companyId` | string | Yes | The ID of the company to attach the contact to |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `company` | object | The company object the contact was attached to |
| ↳ `id` | string | Unique identifier for the company |
| ↳ `type` | string | Object type \(company\) |
| ↳ `company_id` | string | The company_id you defined |
| ↳ `name` | string | Name of the company |
| ↳ `created_at` | number | Unix timestamp when company was created |
| ↳ `updated_at` | number | Unix timestamp when company was updated |
| ↳ `user_count` | number | Number of users in the company |
| ↳ `session_count` | number | Number of sessions |
| ↳ `monthly_spend` | number | Monthly spend amount |
| ↳ `plan` | object | Company plan details |
| `companyId` | string | ID of the company |
| `name` | string | Name of the company |
### `intercom_detach_contact_from_company`
Remove a contact from a company in Intercom
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `contactId` | string | Yes | The ID of the contact to detach from the company |
| `companyId` | string | Yes | The ID of the company to detach the contact from |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `company` | object | The company object the contact was detached from |
| ↳ `id` | string | Unique identifier for the company |
| ↳ `type` | string | Object type \(company\) |
| ↳ `company_id` | string | The company_id you defined |
| ↳ `name` | string | Name of the company |
| `companyId` | string | ID of the company |
| `name` | string | Name of the company |

View File

@@ -6,7 +6,7 @@ description: Access prediction markets and trade on Kalshi
import { BlockInfoCard } from "@/components/ui/block-info-card" import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard <BlockInfoCard
type="kalshi" type="kalshi_v2"
color="#09C285" color="#09C285"
/> />
@@ -36,7 +36,7 @@ Integrate Kalshi prediction markets into the workflow. Can get markets, market,
### `kalshi_get_markets` ### `kalshi_get_markets`
Retrieve a list of prediction markets from Kalshi with optional filtering Retrieve a list of prediction markets from Kalshi with all filtering options (V2 - full API response)
#### Input #### Input
@@ -52,12 +52,12 @@ Retrieve a list of prediction markets from Kalshi with optional filtering
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `markets` | array | Array of market objects | | `markets` | array | Array of market objects with all API fields |
| `paging` | object | Pagination cursor for fetching more results | | `cursor` | string | Pagination cursor for fetching more results |
### `kalshi_get_market` ### `kalshi_get_market`
Retrieve details of a specific prediction market by ticker Retrieve details of a specific prediction market by ticker (V2 - full API response)
#### Input #### Input
@@ -69,11 +69,62 @@ Retrieve details of a specific prediction market by ticker
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `market` | object | Market object with details | | `market` | object | Market object with all API fields |
| ↳ `ticker` | string | Market ticker |
| ↳ `event_ticker` | string | Event ticker |
| ↳ `market_type` | string | Market type |
| ↳ `title` | string | Market title |
| ↳ `subtitle` | string | Market subtitle |
| ↳ `yes_sub_title` | string | Yes outcome subtitle |
| ↳ `no_sub_title` | string | No outcome subtitle |
| ↳ `open_time` | string | Market open time |
| ↳ `close_time` | string | Market close time |
| ↳ `expected_expiration_time` | string | Expected expiration time |
| ↳ `expiration_time` | string | Expiration time |
| ↳ `latest_expiration_time` | string | Latest expiration time |
| ↳ `settlement_timer_seconds` | number | Settlement timer in seconds |
| ↳ `status` | string | Market status |
| ↳ `response_price_units` | string | Response price units |
| ↳ `notional_value` | number | Notional value |
| ↳ `tick_size` | number | Tick size |
| ↳ `yes_bid` | number | Current yes bid price |
| ↳ `yes_ask` | number | Current yes ask price |
| ↳ `no_bid` | number | Current no bid price |
| ↳ `no_ask` | number | Current no ask price |
| ↳ `last_price` | number | Last trade price |
| ↳ `previous_yes_bid` | number | Previous yes bid |
| ↳ `previous_yes_ask` | number | Previous yes ask |
| ↳ `previous_price` | number | Previous price |
| ↳ `volume` | number | Total volume |
| ↳ `volume_24h` | number | 24-hour volume |
| ↳ `liquidity` | number | Market liquidity |
| ↳ `open_interest` | number | Open interest |
| ↳ `result` | string | Market result |
| ↳ `cap_strike` | number | Cap strike |
| ↳ `floor_strike` | number | Floor strike |
| ↳ `can_close_early` | boolean | Can close early |
| ↳ `expiration_value` | string | Expiration value |
| ↳ `category` | string | Market category |
| ↳ `risk_limit_cents` | number | Risk limit in cents |
| ↳ `strike_type` | string | Strike type |
| ↳ `rules_primary` | string | Primary rules |
| ↳ `rules_secondary` | string | Secondary rules |
| ↳ `settlement_source_url` | string | Settlement source URL |
| ↳ `custom_strike` | object | Custom strike object |
| ↳ `underlying` | string | Underlying asset |
| ↳ `settlement_value` | number | Settlement value |
| ↳ `cfd_contract_size` | number | CFD contract size |
| ↳ `yes_fee_fp` | number | Yes fee \(fixed-point\) |
| ↳ `no_fee_fp` | number | No fee \(fixed-point\) |
| ↳ `last_price_fp` | number | Last price \(fixed-point\) |
| ↳ `yes_bid_fp` | number | Yes bid \(fixed-point\) |
| ↳ `yes_ask_fp` | number | Yes ask \(fixed-point\) |
| ↳ `no_bid_fp` | number | No bid \(fixed-point\) |
| ↳ `no_ask_fp` | number | No ask \(fixed-point\) |
### `kalshi_get_events` ### `kalshi_get_events`
Retrieve a list of events from Kalshi with optional filtering Retrieve a list of events from Kalshi with optional filtering (V2 - exact API response)
#### Input #### Input
@@ -90,11 +141,12 @@ Retrieve a list of events from Kalshi with optional filtering
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `events` | array | Array of event objects | | `events` | array | Array of event objects |
| `paging` | object | Pagination cursor for fetching more results | | `milestones` | array | Array of milestone objects \(if requested\) |
| `cursor` | string | Pagination cursor for fetching more results |
### `kalshi_get_event` ### `kalshi_get_event`
Retrieve details of a specific event by ticker Retrieve details of a specific event by ticker (V2 - exact API response)
#### Input #### Input
@@ -107,11 +159,23 @@ Retrieve details of a specific event by ticker
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `event` | object | Event object with details | | `event` | object | Event object with full details matching Kalshi API response |
| ↳ `event_ticker` | string | Event ticker |
| ↳ `series_ticker` | string | Series ticker |
| ↳ `title` | string | Event title |
| ↳ `sub_title` | string | Event subtitle |
| ↳ `mutually_exclusive` | boolean | Mutually exclusive markets |
| ↳ `category` | string | Event category |
| ↳ `collateral_return_type` | string | Collateral return type |
| ↳ `strike_date` | string | Strike date |
| ↳ `strike_period` | string | Strike period |
| ↳ `available_on_brokers` | boolean | Available on brokers |
| ↳ `product_metadata` | object | Product metadata |
| ↳ `markets` | array | Nested markets \(if requested\) |
### `kalshi_get_balance` ### `kalshi_get_balance`
Retrieve your account balance and portfolio value from Kalshi Retrieve your account balance and portfolio value from Kalshi (V2 - exact API response)
#### Input #### Input
@@ -125,11 +189,12 @@ Retrieve your account balance and portfolio value from Kalshi
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `balance` | number | Account balance in cents | | `balance` | number | Account balance in cents |
| `portfolioValue` | number | Portfolio value in cents | | `portfolio_value` | number | Portfolio value in cents |
| `updated_ts` | number | Unix timestamp of last update \(milliseconds\) |
### `kalshi_get_positions` ### `kalshi_get_positions`
Retrieve your open positions from Kalshi Retrieve your open positions from Kalshi (V2 - exact API response)
#### Input #### Input
@@ -147,12 +212,13 @@ Retrieve your open positions from Kalshi
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `positions` | array | Array of position objects | | `market_positions` | array | Array of market position objects |
| `paging` | object | Pagination cursor for fetching more results | | `event_positions` | array | Array of event position objects |
| `cursor` | string | Pagination cursor for fetching more results |
### `kalshi_get_orders` ### `kalshi_get_orders`
Retrieve your orders from Kalshi with optional filtering Retrieve your orders from Kalshi with optional filtering (V2 with full API response)
#### Input #### Input
@@ -170,12 +236,12 @@ Retrieve your orders from Kalshi with optional filtering
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `orders` | array | Array of order objects | | `orders` | array | Array of order objects with full API response fields |
| `paging` | object | Pagination cursor for fetching more results | | `cursor` | string | Pagination cursor for fetching more results |
### `kalshi_get_order` ### `kalshi_get_order`
Retrieve details of a specific order by ID from Kalshi Retrieve details of a specific order by ID from Kalshi (V2 with full API response)
#### Input #### Input
@@ -189,11 +255,44 @@ Retrieve details of a specific order by ID from Kalshi
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `order` | object | Order object with details | | `order` | object | Order object with full API response fields |
| ↳ `order_id` | string | Order ID |
| ↳ `user_id` | string | User ID |
| ↳ `client_order_id` | string | Client order ID |
| ↳ `ticker` | string | Market ticker |
| ↳ `side` | string | Order side \(yes/no\) |
| ↳ `action` | string | Action \(buy/sell\) |
| ↳ `type` | string | Order type \(limit/market\) |
| ↳ `status` | string | Order status \(resting/canceled/executed\) |
| ↳ `yes_price` | number | Yes price in cents |
| ↳ `no_price` | number | No price in cents |
| ↳ `yes_price_dollars` | string | Yes price in dollars |
| ↳ `no_price_dollars` | string | No price in dollars |
| ↳ `fill_count` | number | Filled contract count |
| ↳ `fill_count_fp` | string | Filled count \(fixed-point\) |
| ↳ `remaining_count` | number | Remaining contracts |
| ↳ `remaining_count_fp` | string | Remaining count \(fixed-point\) |
| ↳ `initial_count` | number | Initial contract count |
| ↳ `initial_count_fp` | string | Initial count \(fixed-point\) |
| ↳ `taker_fees` | number | Taker fees in cents |
| ↳ `maker_fees` | number | Maker fees in cents |
| ↳ `taker_fees_dollars` | string | Taker fees in dollars |
| ↳ `maker_fees_dollars` | string | Maker fees in dollars |
| ↳ `taker_fill_cost` | number | Taker fill cost in cents |
| ↳ `maker_fill_cost` | number | Maker fill cost in cents |
| ↳ `taker_fill_cost_dollars` | string | Taker fill cost in dollars |
| ↳ `maker_fill_cost_dollars` | string | Maker fill cost in dollars |
| ↳ `queue_position` | number | Queue position \(deprecated\) |
| ↳ `expiration_time` | string | Order expiration time |
| ↳ `created_time` | string | Order creation time |
| ↳ `last_update_time` | string | Last update time |
| ↳ `self_trade_prevention_type` | string | Self-trade prevention type |
| ↳ `order_group_id` | string | Order group ID |
| ↳ `cancel_order_on_pause` | boolean | Cancel on market pause |
### `kalshi_get_orderbook` ### `kalshi_get_orderbook`
Retrieve the orderbook (yes and no bids) for a specific market Retrieve the orderbook (yes and no bids) for a specific market (V2 - includes depth and fp fields)
#### Input #### Input
@@ -205,11 +304,18 @@ Retrieve the orderbook (yes and no bids) for a specific market
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `orderbook` | object | Orderbook with yes/no bids and asks | | `orderbook` | object | Orderbook with yes/no bids \(legacy integer counts\) |
| ↳ `yes` | array | Yes side bids as tuples \[price_cents, count\] |
| ↳ `no` | array | No side bids as tuples \[price_cents, count\] |
| ↳ `yes_dollars` | array | Yes side bids as tuples \[dollars_string, count\] |
| ↳ `no_dollars` | array | No side bids as tuples \[dollars_string, count\] |
| `orderbook_fp` | object | Orderbook with fixed-point counts \(preferred\) |
| ↳ `yes_dollars` | array | Yes side bids as tuples \[dollars_string, fp_count_string\] |
| ↳ `no_dollars` | array | No side bids as tuples \[dollars_string, fp_count_string\] |
### `kalshi_get_trades` ### `kalshi_get_trades`
Retrieve recent trades across all markets Retrieve recent trades with additional filtering options (V2 - includes trade_id and count_fp)
#### Input #### Input
@@ -222,12 +328,12 @@ Retrieve recent trades across all markets
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `trades` | array | Array of trade objects | | `trades` | array | Array of trade objects with trade_id and count_fp |
| `paging` | object | Pagination cursor for fetching more results | | `cursor` | string | Pagination cursor for fetching more results |
### `kalshi_get_candlesticks` ### `kalshi_get_candlesticks`
Retrieve OHLC candlestick data for a specific market Retrieve OHLC candlestick data for a specific market (V2 - full API response)
#### Input #### Input
@@ -243,7 +349,8 @@ Retrieve OHLC candlestick data for a specific market
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `candlesticks` | array | Array of OHLC candlestick data | | `ticker` | string | Market ticker |
| `candlesticks` | array | Array of OHLC candlestick data with nested bid/ask/price objects |
### `kalshi_get_fills` ### `kalshi_get_fills`
@@ -266,12 +373,12 @@ Retrieve your portfolio
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `fills` | array | Array of fill/trade objects | | `fills` | array | Array of fill/trade objects with all API fields |
| `paging` | object | Pagination cursor for fetching more results | | `cursor` | string | Pagination cursor for fetching more results |
### `kalshi_get_series_by_ticker` ### `kalshi_get_series_by_ticker`
Retrieve details of a specific market series by ticker Retrieve details of a specific market series by ticker (V2 - exact API response)
#### Input #### Input
@@ -283,11 +390,25 @@ Retrieve details of a specific market series by ticker
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `series` | object | Series object with details | | `series` | object | Series object with full details matching Kalshi API response |
| ↳ `ticker` | string | Series ticker |
| ↳ `title` | string | Series title |
| ↳ `frequency` | string | Event frequency |
| ↳ `category` | string | Series category |
| ↳ `tags` | array | Series tags |
| ↳ `settlement_sources` | array | Settlement sources |
| ↳ `contract_url` | string | Contract URL |
| ↳ `contract_terms_url` | string | Contract terms URL |
| ↳ `fee_type` | string | Fee type |
| ↳ `fee_multiplier` | number | Fee multiplier |
| ↳ `additional_prohibitions` | array | Additional prohibitions |
| ↳ `product_metadata` | object | Product metadata |
| ↳ `volume` | number | Series volume |
| ↳ `volume_fp` | number | Volume \(fixed-point\) |
### `kalshi_get_exchange_status` ### `kalshi_get_exchange_status`
Retrieve the current status of the Kalshi exchange (trading and exchange activity) Retrieve the current status of the Kalshi exchange (V2 - exact API response)
#### Input #### Input
@@ -298,11 +419,13 @@ Retrieve the current status of the Kalshi exchange (trading and exchange activit
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `status` | object | Exchange status with trading_active and exchange_active flags | | `exchange_active` | boolean | Whether the exchange is active |
| `trading_active` | boolean | Whether trading is active |
| `exchange_estimated_resume_time` | string | Estimated time when exchange will resume \(if inactive\) |
### `kalshi_create_order` ### `kalshi_create_order`
Create a new order on a Kalshi prediction market Create a new order on a Kalshi prediction market (V2 with full API response)
#### Input #### Input
@@ -332,11 +455,44 @@ Create a new order on a Kalshi prediction market
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `order` | object | The created order object | | `order` | object | The created order object with full API response fields |
| ↳ `order_id` | string | Order ID |
| ↳ `user_id` | string | User ID |
| ↳ `client_order_id` | string | Client order ID |
| ↳ `ticker` | string | Market ticker |
| ↳ `side` | string | Order side \(yes/no\) |
| ↳ `action` | string | Action \(buy/sell\) |
| ↳ `type` | string | Order type \(limit/market\) |
| ↳ `status` | string | Order status \(resting/canceled/executed\) |
| ↳ `yes_price` | number | Yes price in cents |
| ↳ `no_price` | number | No price in cents |
| ↳ `yes_price_dollars` | string | Yes price in dollars |
| ↳ `no_price_dollars` | string | No price in dollars |
| ↳ `fill_count` | number | Filled contract count |
| ↳ `fill_count_fp` | string | Filled count \(fixed-point\) |
| ↳ `remaining_count` | number | Remaining contracts |
| ↳ `remaining_count_fp` | string | Remaining count \(fixed-point\) |
| ↳ `initial_count` | number | Initial contract count |
| ↳ `initial_count_fp` | string | Initial count \(fixed-point\) |
| ↳ `taker_fees` | number | Taker fees in cents |
| ↳ `maker_fees` | number | Maker fees in cents |
| ↳ `taker_fees_dollars` | string | Taker fees in dollars |
| ↳ `maker_fees_dollars` | string | Maker fees in dollars |
| ↳ `taker_fill_cost` | number | Taker fill cost in cents |
| ↳ `maker_fill_cost` | number | Maker fill cost in cents |
| ↳ `taker_fill_cost_dollars` | string | Taker fill cost in dollars |
| ↳ `maker_fill_cost_dollars` | string | Maker fill cost in dollars |
| ↳ `queue_position` | number | Queue position \(deprecated\) |
| ↳ `expiration_time` | string | Order expiration time |
| ↳ `created_time` | string | Order creation time |
| ↳ `last_update_time` | string | Last update time |
| ↳ `self_trade_prevention_type` | string | Self-trade prevention type |
| ↳ `order_group_id` | string | Order group ID |
| ↳ `cancel_order_on_pause` | boolean | Cancel on market pause |
### `kalshi_cancel_order` ### `kalshi_cancel_order`
Cancel an existing order on Kalshi Cancel an existing order on Kalshi (V2 with full API response)
#### Input #### Input
@@ -350,12 +506,46 @@ Cancel an existing order on Kalshi
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `order` | object | The canceled order object | | `order` | object | The canceled order object with full API response fields |
| `reducedBy` | number | Number of contracts canceled | | ↳ `order_id` | string | Order ID |
| ↳ `user_id` | string | User ID |
| ↳ `client_order_id` | string | Client order ID |
| ↳ `ticker` | string | Market ticker |
| ↳ `side` | string | Order side \(yes/no\) |
| ↳ `action` | string | Action \(buy/sell\) |
| ↳ `type` | string | Order type \(limit/market\) |
| ↳ `status` | string | Order status \(resting/canceled/executed\) |
| ↳ `yes_price` | number | Yes price in cents |
| ↳ `no_price` | number | No price in cents |
| ↳ `yes_price_dollars` | string | Yes price in dollars |
| ↳ `no_price_dollars` | string | No price in dollars |
| ↳ `fill_count` | number | Filled contract count |
| ↳ `fill_count_fp` | string | Filled count \(fixed-point\) |
| ↳ `remaining_count` | number | Remaining contracts |
| ↳ `remaining_count_fp` | string | Remaining count \(fixed-point\) |
| ↳ `initial_count` | number | Initial contract count |
| ↳ `initial_count_fp` | string | Initial count \(fixed-point\) |
| ↳ `taker_fees` | number | Taker fees in cents |
| ↳ `maker_fees` | number | Maker fees in cents |
| ↳ `taker_fees_dollars` | string | Taker fees in dollars |
| ↳ `maker_fees_dollars` | string | Maker fees in dollars |
| ↳ `taker_fill_cost` | number | Taker fill cost in cents |
| ↳ `maker_fill_cost` | number | Maker fill cost in cents |
| ↳ `taker_fill_cost_dollars` | string | Taker fill cost in dollars |
| ↳ `maker_fill_cost_dollars` | string | Maker fill cost in dollars |
| ↳ `queue_position` | number | Queue position \(deprecated\) |
| ↳ `expiration_time` | string | Order expiration time |
| ↳ `created_time` | string | Order creation time |
| ↳ `last_update_time` | string | Last update time |
| ↳ `self_trade_prevention_type` | string | Self-trade prevention type |
| ↳ `order_group_id` | string | Order group ID |
| ↳ `cancel_order_on_pause` | boolean | Cancel on market pause |
| `reduced_by` | number | Number of contracts canceled |
| `reduced_by_fp` | string | Number of contracts canceled in fixed-point format |
### `kalshi_amend_order` ### `kalshi_amend_order`
Modify the price or quantity of an existing order on Kalshi Modify the price or quantity of an existing order on Kalshi (V2 with full API response)
#### Input #### Input
@@ -379,6 +569,63 @@ Modify the price or quantity of an existing order on Kalshi
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `order` | object | The amended order object | | `old_order` | object | The original order object before amendment |
| ↳ `order_id` | string | Order ID |
| ↳ `user_id` | string | User ID |
| ↳ `ticker` | string | Market ticker |
| ↳ `event_ticker` | string | Event ticker |
| ↳ `status` | string | Order status |
| ↳ `side` | string | Order side \(yes/no\) |
| ↳ `type` | string | Order type \(limit/market\) |
| ↳ `yes_price` | number | Yes price in cents |
| ↳ `no_price` | number | No price in cents |
| ↳ `action` | string | Action \(buy/sell\) |
| ↳ `count` | number | Number of contracts |
| ↳ `remaining_count` | number | Remaining contracts |
| ↳ `created_time` | string | Order creation time |
| ↳ `expiration_time` | string | Order expiration time |
| ↳ `order_group_id` | string | Order group ID |
| ↳ `client_order_id` | string | Client order ID |
| ↳ `place_count` | number | Place count |
| ↳ `decrease_count` | number | Decrease count |
| ↳ `queue_position` | number | Queue position |
| ↳ `maker_fill_count` | number | Maker fill count |
| ↳ `taker_fill_count` | number | Taker fill count |
| ↳ `maker_fees` | number | Maker fees |
| ↳ `taker_fees` | number | Taker fees |
| ↳ `last_update_time` | string | Last update time |
| ↳ `take_profit_order_id` | string | Take profit order ID |
| ↳ `stop_loss_order_id` | string | Stop loss order ID |
| ↳ `amend_count` | number | Amend count |
| ↳ `amend_taker_fill_count` | number | Amend taker fill count |
| `order` | object | The amended order object with full API response fields |
| ↳ `order_id` | string | Order ID |
| ↳ `user_id` | string | User ID |
| ↳ `ticker` | string | Market ticker |
| ↳ `event_ticker` | string | Event ticker |
| ↳ `status` | string | Order status |
| ↳ `side` | string | Order side \(yes/no\) |
| ↳ `type` | string | Order type \(limit/market\) |
| ↳ `yes_price` | number | Yes price in cents |
| ↳ `no_price` | number | No price in cents |
| ↳ `action` | string | Action \(buy/sell\) |
| ↳ `count` | number | Number of contracts |
| ↳ `remaining_count` | number | Remaining contracts |
| ↳ `created_time` | string | Order creation time |
| ↳ `expiration_time` | string | Order expiration time |
| ↳ `order_group_id` | string | Order group ID |
| ↳ `client_order_id` | string | Client order ID |
| ↳ `place_count` | number | Place count |
| ↳ `decrease_count` | number | Decrease count |
| ↳ `queue_position` | number | Queue position |
| ↳ `maker_fill_count` | number | Maker fill count |
| ↳ `taker_fill_count` | number | Taker fill count |
| ↳ `maker_fees` | number | Maker fees |
| ↳ `taker_fees` | number | Taker fees |
| ↳ `last_update_time` | string | Last update time |
| ↳ `take_profit_order_id` | string | Take profit order ID |
| ↳ `stop_loss_order_id` | string | Stop loss order ID |
| ↳ `amend_count` | number | Amend count |
| ↳ `amend_taker_fill_count` | number | Amend taker fill count |

View File

@@ -97,7 +97,6 @@
"shopify", "shopify",
"slack", "slack",
"smtp", "smtp",
"spotify",
"sqs", "sqs",
"ssh", "ssh",
"stagehand", "stagehand",

View File

@@ -29,7 +29,7 @@ By using these documented API endpoints, you can seamlessly integrate Polymarket
## Usage Instructions ## Usage Instructions
Integrate Polymarket prediction markets into the workflow. Can get markets, market, events, event, tags, series, orderbook, price, midpoint, price history, last trade price, spread, tick size, positions, trades, and search. Integrate Polymarket prediction markets into the workflow. Can get markets, market, events, event, tags, series, orderbook, price, midpoint, price history, last trade price, spread, tick size, positions, trades, activity, leaderboard, holders, and search.
@@ -43,7 +43,7 @@ Retrieve a list of prediction markets from Polymarket with optional filtering
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `closed` | string | No | Filter by closed status \(true/false\). Use false for active markets only. | | `closed` | string | No | Filter by closed status \(true/false\). Use false for open markets only. |
| `order` | string | No | Sort field \(e.g., volumeNum, liquidityNum, startDate, endDate, createdAt\) | | `order` | string | No | Sort field \(e.g., volumeNum, liquidityNum, startDate, endDate, createdAt\) |
| `ascending` | string | No | Sort direction \(true for ascending, false for descending\) | | `ascending` | string | No | Sort direction \(true for ascending, false for descending\) |
| `tagId` | string | No | Filter by tag ID | | `tagId` | string | No | Filter by tag ID |
@@ -55,6 +55,21 @@ Retrieve a list of prediction markets from Polymarket with optional filtering
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `markets` | array | Array of market objects | | `markets` | array | Array of market objects |
| ↳ `id` | string | Market ID |
| ↳ `question` | string | Market question |
| ↳ `conditionId` | string | Condition ID |
| ↳ `slug` | string | Market slug |
| ↳ `endDate` | string | End date |
| ↳ `image` | string | Market image URL |
| ↳ `outcomes` | string | Outcomes JSON string |
| ↳ `outcomePrices` | string | Outcome prices JSON string |
| ↳ `volume` | string | Total volume |
| ↳ `liquidity` | string | Total liquidity |
| ↳ `active` | boolean | Whether market is active |
| ↳ `closed` | boolean | Whether market is closed |
| ↳ `volumeNum` | number | Volume as number |
| ↳ `liquidityNum` | number | Liquidity as number |
| ↳ `clobTokenIds` | array | CLOB token IDs |
### `polymarket_get_market` ### `polymarket_get_market`
@@ -72,6 +87,28 @@ Retrieve details of a specific prediction market by ID or slug
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `market` | object | Market object with details | | `market` | object | Market object with details |
| ↳ `id` | string | Market ID |
| ↳ `question` | string | Market question |
| ↳ `conditionId` | string | Condition ID |
| ↳ `slug` | string | Market slug |
| ↳ `resolutionSource` | string | Resolution source |
| ↳ `endDate` | string | End date |
| ↳ `startDate` | string | Start date |
| ↳ `image` | string | Market image URL |
| ↳ `icon` | string | Market icon URL |
| ↳ `description` | string | Market description |
| ↳ `outcomes` | string | Outcomes JSON string |
| ↳ `outcomePrices` | string | Outcome prices JSON string |
| ↳ `volume` | string | Total volume |
| ↳ `liquidity` | string | Total liquidity |
| ↳ `active` | boolean | Whether market is active |
| ↳ `closed` | boolean | Whether market is closed |
| ↳ `archived` | boolean | Whether market is archived |
| ↳ `volumeNum` | number | Volume as number |
| ↳ `liquidityNum` | number | Liquidity as number |
| ↳ `clobTokenIds` | array | CLOB token IDs |
| ↳ `acceptingOrders` | boolean | Whether accepting orders |
| ↳ `negRisk` | boolean | Whether negative risk |
### `polymarket_get_events` ### `polymarket_get_events`
@@ -81,7 +118,7 @@ Retrieve a list of events from Polymarket with optional filtering
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `closed` | string | No | Filter by closed status \(true/false\). Use false for active events only. | | `closed` | string | No | Filter by closed status \(true/false\). Use false for open events only. |
| `order` | string | No | Sort field \(e.g., volume, liquidity, startDate, endDate, createdAt\) | | `order` | string | No | Sort field \(e.g., volume, liquidity, startDate, endDate, createdAt\) |
| `ascending` | string | No | Sort direction \(true for ascending, false for descending\) | | `ascending` | string | No | Sort direction \(true for ascending, false for descending\) |
| `tagId` | string | No | Filter by tag ID | | `tagId` | string | No | Filter by tag ID |
@@ -93,6 +130,21 @@ Retrieve a list of events from Polymarket with optional filtering
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `events` | array | Array of event objects | | `events` | array | Array of event objects |
| ↳ `id` | string | Event ID |
| ↳ `ticker` | string | Event ticker |
| ↳ `slug` | string | Event slug |
| ↳ `title` | string | Event title |
| ↳ `description` | string | Event description |
| ↳ `startDate` | string | Start date |
| ↳ `endDate` | string | End date |
| ↳ `image` | string | Event image URL |
| ↳ `icon` | string | Event icon URL |
| ↳ `active` | boolean | Whether event is active |
| ↳ `closed` | boolean | Whether event is closed |
| ↳ `archived` | boolean | Whether event is archived |
| ↳ `liquidity` | number | Total liquidity |
| ↳ `volume` | number | Total volume |
| ↳ `markets` | array | Array of markets in this event |
### `polymarket_get_event` ### `polymarket_get_event`
@@ -110,6 +162,24 @@ Retrieve details of a specific event by ID or slug
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `event` | object | Event object with details | | `event` | object | Event object with details |
| ↳ `id` | string | Event ID |
| ↳ `ticker` | string | Event ticker |
| ↳ `slug` | string | Event slug |
| ↳ `title` | string | Event title |
| ↳ `description` | string | Event description |
| ↳ `startDate` | string | Start date |
| ↳ `creationDate` | string | Creation date |
| ↳ `endDate` | string | End date |
| ↳ `image` | string | Event image URL |
| ↳ `icon` | string | Event icon URL |
| ↳ `active` | boolean | Whether event is active |
| ↳ `closed` | boolean | Whether event is closed |
| ↳ `archived` | boolean | Whether event is archived |
| ↳ `liquidity` | number | Total liquidity |
| ↳ `volume` | number | Total volume |
| ↳ `openInterest` | number | Open interest |
| ↳ `commentCount` | number | Comment count |
| ↳ `markets` | array | Array of markets in this event |
### `polymarket_get_tags` ### `polymarket_get_tags`
@@ -126,7 +196,12 @@ Retrieve available tags for filtering markets from Polymarket
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `tags` | array | Array of tag objects with id, label, and slug | | `tags` | array | Array of tag objects |
| ↳ `id` | string | Tag ID |
| ↳ `label` | string | Tag label |
| ↳ `slug` | string | Tag slug |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
### `polymarket_search` ### `polymarket_search`
@@ -138,13 +213,28 @@ Search for markets, events, and profiles on Polymarket
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `query` | string | Yes | Search query term | | `query` | string | Yes | Search query term |
| `limit` | string | No | Number of results per page \(max 50\) | | `limit` | string | No | Number of results per page \(max 50\) |
| `offset` | string | No | Pagination offset | | `page` | string | No | Page number for pagination \(1-indexed\) |
| `cache` | string | No | Enable caching \(true/false\) |
| `eventsStatus` | string | No | Filter events by status |
| `limitPerType` | string | No | Limit results per type \(markets, events, profiles\) |
| `eventsTag` | string | No | Filter by event tags \(comma-separated\) |
| `sort` | string | No | Sort field |
| `ascending` | string | No | Sort direction \(true for ascending, false for descending\) |
| `searchTags` | string | No | Include tags in search results \(true/false\) |
| `searchProfiles` | string | No | Include profiles in search results \(true/false\) |
| `recurrence` | string | No | Filter by recurrence type |
| `excludeTagId` | string | No | Exclude events with these tag IDs \(comma-separated\) |
| `keepClosedMarkets` | string | No | Include closed markets in results \(0 or 1\) |
#### Output #### Output
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `results` | object | Search results containing markets, events, and profiles arrays | | `results` | object | Search results containing markets, events, tags, and profiles arrays |
| ↳ `markets` | array | Array of matching market objects |
| ↳ `events` | array | Array of matching event objects |
| ↳ `tags` | array | Array of matching tag objects |
| ↳ `profiles` | array | Array of matching profile objects |
### `polymarket_get_series` ### `polymarket_get_series`
@@ -162,6 +252,21 @@ Retrieve series (related market groups) from Polymarket
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `series` | array | Array of series objects | | `series` | array | Array of series objects |
| ↳ `id` | string | Series ID |
| ↳ `ticker` | string | Series ticker |
| ↳ `slug` | string | Series slug |
| ↳ `title` | string | Series title |
| ↳ `seriesType` | string | Series type |
| ↳ `recurrence` | string | Recurrence pattern |
| ↳ `image` | string | Series image URL |
| ↳ `icon` | string | Series icon URL |
| ↳ `active` | boolean | Whether series is active |
| ↳ `closed` | boolean | Whether series is closed |
| ↳ `archived` | boolean | Whether series is archived |
| ↳ `featured` | boolean | Whether series is featured |
| ↳ `volume` | number | Total volume |
| ↳ `liquidity` | number | Total liquidity |
| ↳ `eventCount` | number | Number of events in series |
### `polymarket_get_series_by_id` ### `polymarket_get_series_by_id`
@@ -178,6 +283,23 @@ Retrieve a specific series (related market group) by ID from Polymarket
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `series` | object | Series object with details | | `series` | object | Series object with details |
| ↳ `id` | string | Series ID |
| ↳ `ticker` | string | Series ticker |
| ↳ `slug` | string | Series slug |
| ↳ `title` | string | Series title |
| ↳ `seriesType` | string | Series type |
| ↳ `recurrence` | string | Recurrence pattern |
| ↳ `image` | string | Series image URL |
| ↳ `icon` | string | Series icon URL |
| ↳ `active` | boolean | Whether series is active |
| ↳ `closed` | boolean | Whether series is closed |
| ↳ `archived` | boolean | Whether series is archived |
| ↳ `featured` | boolean | Whether series is featured |
| ↳ `volume` | number | Total volume |
| ↳ `liquidity` | number | Total liquidity |
| ↳ `commentCount` | number | Comment count |
| ↳ `eventCount` | number | Number of events in series |
| ↳ `events` | array | Array of events in this series |
### `polymarket_get_orderbook` ### `polymarket_get_orderbook`
@@ -194,6 +316,21 @@ Retrieve the order book summary for a specific token
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `orderbook` | object | Order book with bids and asks arrays | | `orderbook` | object | Order book with bids and asks arrays |
| ↳ `market` | string | Market identifier |
| ↳ `asset_id` | string | Asset token ID |
| ↳ `hash` | string | Order book hash |
| ↳ `timestamp` | string | Timestamp |
| ↳ `bids` | array | Bid orders |
| ↳ `price` | string | Bid price |
| ↳ `size` | string | Bid size |
| ↳ `price` | string | Ask price |
| ↳ `size` | string | Ask size |
| ↳ `asks` | array | Ask orders |
| ↳ `price` | string | Ask price |
| ↳ `size` | string | Ask size |
| ↳ `min_order_size` | string | Minimum order size |
| ↳ `tick_size` | string | Tick size |
| ↳ `neg_risk` | boolean | Whether negative risk |
### `polymarket_get_price` ### `polymarket_get_price`
@@ -246,7 +383,9 @@ Retrieve historical price data for a specific market token
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `history` | array | Array of price history entries with timestamp \(t\) and price \(p\) | | `history` | array | Array of price history entries |
| ↳ `t` | number | Unix timestamp |
| ↳ `p` | number | Price at timestamp |
### `polymarket_get_last_trade_price` ### `polymarket_get_last_trade_price`
@@ -263,6 +402,7 @@ Retrieve the last trade price for a specific token
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `price` | string | Last trade price | | `price` | string | Last trade price |
| `side` | string | Side of the last trade \(BUY or SELL\) |
### `polymarket_get_spread` ### `polymarket_get_spread`
@@ -278,7 +418,8 @@ Retrieve the bid-ask spread for a specific token
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `spread` | object | Bid-ask spread with bid and ask prices | | `spread` | object | Spread value between bid and ask |
| ↳ `spread` | string | The spread value |
### `polymarket_get_tick_size` ### `polymarket_get_tick_size`
@@ -305,13 +446,47 @@ Retrieve user positions from Polymarket
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `user` | string | Yes | User wallet address | | `user` | string | Yes | User wallet address |
| `market` | string | No | Optional market ID to filter positions | | `market` | string | No | Condition IDs to filter positions \(comma-separated, mutually exclusive with eventId\) |
| `eventId` | string | No | Event ID to filter positions \(mutually exclusive with market\) |
| `sizeThreshold` | string | No | Minimum position size threshold \(default: 1\) |
| `redeemable` | string | No | Filter for redeemable positions only \(true/false\) |
| `mergeable` | string | No | Filter for mergeable positions only \(true/false\) |
| `sortBy` | string | No | Sort field \(TOKENS, CURRENT, INITIAL, CASHPNL, PERCENTPNL, TITLE, RESOLVING, PRICE, AVGPRICE\) |
| `sortDirection` | string | No | Sort direction \(ASC or DESC\) |
| `title` | string | No | Search filter by title |
| `limit` | string | No | Number of results per page |
| `offset` | string | No | Pagination offset |
#### Output #### Output
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `positions` | array | Array of position objects | | `positions` | array | Array of position objects |
| ↳ `proxyWallet` | string | Proxy wallet address |
| ↳ `asset` | string | Asset token ID |
| ↳ `conditionId` | string | Condition ID |
| ↳ `size` | number | Position size |
| ↳ `avgPrice` | number | Average price |
| ↳ `initialValue` | number | Initial value |
| ↳ `currentValue` | number | Current value |
| ↳ `cashPnl` | number | Cash profit/loss |
| ↳ `percentPnl` | number | Percent profit/loss |
| ↳ `totalBought` | number | Total bought |
| ↳ `realizedPnl` | number | Realized profit/loss |
| ↳ `percentRealizedPnl` | number | Percent realized profit/loss |
| ↳ `curPrice` | number | Current price |
| ↳ `redeemable` | boolean | Whether position is redeemable |
| ↳ `mergeable` | boolean | Whether position is mergeable |
| ↳ `title` | string | Market title |
| ↳ `slug` | string | Market slug |
| ↳ `icon` | string | Market icon URL |
| ↳ `eventSlug` | string | Event slug |
| ↳ `outcome` | string | Outcome name |
| ↳ `outcomeIndex` | number | Outcome index |
| ↳ `oppositeOutcome` | string | Opposite outcome name |
| ↳ `oppositeAsset` | string | Opposite asset token ID |
| ↳ `endDate` | string | End date |
| ↳ `negativeRisk` | boolean | Whether negative risk |
### `polymarket_get_trades` ### `polymarket_get_trades`
@@ -322,8 +497,13 @@ Retrieve trade history from Polymarket
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `user` | string | No | User wallet address to filter trades | | `user` | string | No | User wallet address to filter trades |
| `market` | string | No | Market ID to filter trades | | `market` | string | No | Market/condition ID to filter trades \(mutually exclusive with eventId\) |
| `limit` | string | No | Number of results per page \(max 50\) | | `eventId` | string | No | Event ID to filter trades \(mutually exclusive with market\) |
| `side` | string | No | Trade direction filter \(BUY or SELL\) |
| `takerOnly` | string | No | Filter for taker trades only \(true/false, default: true\) |
| `filterType` | string | No | Filter type \(CASH or TOKENS\) - requires filterAmount |
| `filterAmount` | string | No | Filter amount threshold - requires filterType |
| `limit` | string | No | Number of results per page \(default: 100, max: 10000\) |
| `offset` | string | No | Pagination offset \(skip this many results\) | | `offset` | string | No | Pagination offset \(skip this many results\) |
#### Output #### Output
@@ -331,5 +511,141 @@ Retrieve trade history from Polymarket
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `trades` | array | Array of trade objects | | `trades` | array | Array of trade objects |
| ↳ `proxyWallet` | string | Proxy wallet address |
| ↳ `side` | string | Trade side \(BUY or SELL\) |
| ↳ `asset` | string | Asset token ID |
| ↳ `conditionId` | string | Condition ID |
| ↳ `size` | number | Trade size |
| ↳ `price` | number | Trade price |
| ↳ `timestamp` | number | Unix timestamp |
| ↳ `title` | string | Market title |
| ↳ `slug` | string | Market slug |
| ↳ `icon` | string | Market icon URL |
| ↳ `eventSlug` | string | Event slug |
| ↳ `outcome` | string | Outcome name |
| ↳ `outcomeIndex` | number | Outcome index |
| ↳ `name` | string | Trader name |
| ↳ `pseudonym` | string | Trader pseudonym |
| ↳ `bio` | string | Trader bio |
| ↳ `profileImage` | string | Profile image URL |
| ↳ `profileImageOptimized` | string | Optimized profile image URL |
| ↳ `transactionHash` | string | Transaction hash |
### `polymarket_get_activity`
Retrieve on-chain activity for a user including trades, splits, merges, redemptions, rewards, and conversions
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `user` | string | Yes | User wallet address \(0x-prefixed\) |
| `limit` | string | No | Maximum results \(default: 100, max: 500\) |
| `offset` | string | No | Pagination offset \(default: 0, max: 10000\) |
| `market` | string | No | Comma-separated condition IDs \(mutually exclusive with eventId\) |
| `eventId` | string | No | Comma-separated event IDs \(mutually exclusive with market\) |
| `type` | string | No | Activity type filter: TRADE, SPLIT, MERGE, REDEEM, REWARD, CONVERSION, MAKER_REBATE |
| `start` | number | No | Start timestamp \(Unix seconds\) |
| `end` | number | No | End timestamp \(Unix seconds\) |
| `sortBy` | string | No | Sort field: TIMESTAMP, TOKENS, or CASH \(default: TIMESTAMP\) |
| `sortDirection` | string | No | Sort direction: ASC or DESC \(default: DESC\) |
| `side` | string | No | Trade side filter: BUY or SELL \(only applies to trades\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `activity` | array | Array of activity entries |
| ↳ `proxyWallet` | string | User proxy wallet address |
| ↳ `timestamp` | number | Unix timestamp of activity |
| ↳ `conditionId` | string | Market condition ID |
| ↳ `type` | string | Activity type \(TRADE, SPLIT, MERGE, REDEEM, REWARD, CONVERSION\) |
| ↳ `size` | number | Size in tokens |
| ↳ `usdcSize` | number | Size in USDC |
| ↳ `transactionHash` | string | Blockchain transaction hash |
| ↳ `price` | number | Price \(for trades\) |
| ↳ `asset` | string | Asset/token ID |
| ↳ `side` | string | Trade side \(BUY/SELL\) |
| ↳ `outcomeIndex` | number | Outcome index |
| ↳ `title` | string | Market title |
| ↳ `slug` | string | Market slug |
| ↳ `icon` | string | Market icon URL |
| ↳ `eventSlug` | string | Event slug |
| ↳ `outcome` | string | Outcome name |
| ↳ `name` | string | User display name |
| ↳ `pseudonym` | string | User pseudonym |
| ↳ `bio` | string | User bio |
| ↳ `profileImage` | string | User profile image URL |
| ↳ `profileImageOptimized` | string | Optimized profile image URL |
### `polymarket_get_leaderboard`
Retrieve trader leaderboard rankings by profit/loss or volume
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `category` | string | No | Category filter: OVERALL, POLITICS, SPORTS, CRYPTO, CULTURE, MENTIONS, WEATHER, ECONOMICS, TECH, FINANCE \(default: OVERALL\) |
| `timePeriod` | string | No | Time period: DAY, WEEK, MONTH, ALL \(default: DAY\) |
| `orderBy` | string | No | Order by: PNL or VOL \(default: PNL\) |
| `limit` | string | No | Number of results \(1-50, default: 25\) |
| `offset` | string | No | Pagination offset \(0-1000, default: 0\) |
| `user` | string | No | Filter by specific user wallet address |
| `userName` | string | No | Filter by username |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `leaderboard` | array | Array of leaderboard entries |
| ↳ `rank` | string | Leaderboard rank position |
| ↳ `proxyWallet` | string | User proxy wallet address |
| ↳ `userName` | string | User display name |
| ↳ `vol` | number | Trading volume |
| ↳ `pnl` | number | Profit and loss |
| ↳ `profileImage` | string | User profile image URL |
| ↳ `xUsername` | string | Twitter/X username |
| ↳ `verifiedBadge` | boolean | Whether user has verified badge |
### `polymarket_get_holders`
Retrieve top holders of a specific market token
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `market` | string | Yes | Comma-separated list of condition IDs |
| `limit` | string | No | Number of holders to return \(0-20, default: 20\) |
| `minBalance` | string | No | Minimum balance threshold \(default: 1\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `holders` | array | Array of market holder groups by token |
| ↳ `token` | string | Token/asset ID |
| ↳ `holders` | array | Array of holders for this token |
| ↳ `proxyWallet` | string | Holder wallet address |
| ↳ `bio` | string | Holder bio |
| ↳ `asset` | string | Asset ID |
| ↳ `pseudonym` | string | Holder pseudonym |
| ↳ `amount` | number | Amount held |
| ↳ `displayUsernamePublic` | boolean | Whether username is publicly displayed |
| ↳ `outcomeIndex` | number | Outcome index |
| ↳ `name` | string | Holder display name |
| ↳ `profileImage` | string | Profile image URL |
| ↳ `profileImageOptimized` | string | Optimized profile image URL |
| ↳ `proxyWallet` | string | Holder wallet address |
| ↳ `bio` | string | Holder bio |
| ↳ `asset` | string | Asset ID |
| ↳ `pseudonym` | string | Holder pseudonym |
| ↳ `amount` | number | Amount held |
| ↳ `displayUsernamePublic` | boolean | Whether username is publicly displayed |
| ↳ `outcomeIndex` | number | Outcome index |
| ↳ `name` | string | Holder display name |
| ↳ `profileImage` | string | Profile image URL |
| ↳ `profileImageOptimized` | string | Optimized profile image URL |

File diff suppressed because it is too large Load Diff

View File

@@ -299,7 +299,7 @@ Upload a file to a Supabase storage bucket
| `bucket` | string | Yes | The name of the storage bucket | | `bucket` | string | Yes | The name of the storage bucket |
| `fileName` | string | Yes | The name of the file \(e.g., "document.pdf", "image.jpg"\) | | `fileName` | string | Yes | The name of the file \(e.g., "document.pdf", "image.jpg"\) |
| `path` | string | No | Optional folder path \(e.g., "folder/subfolder/"\) | | `path` | string | No | Optional folder path \(e.g., "folder/subfolder/"\) |
| `fileContent` | string | Yes | The file content \(base64 encoded for binary files, or plain text\) | | `fileData` | json | Yes | File to upload - UserFile object \(basic mode\) or string content \(advanced mode: base64 or plain text\). Supports data URLs. |
| `contentType` | string | No | MIME type of the file \(e.g., "image/jpeg", "text/plain"\) | | `contentType` | string | No | MIME type of the file \(e.g., "image/jpeg", "text/plain"\) |
| `upsert` | boolean | No | If true, overwrites existing file \(default: false\) | | `upsert` | boolean | No | If true, overwrites existing file \(default: false\) |
| `apiKey` | string | Yes | Your Supabase service role secret key | | `apiKey` | string | Yes | Your Supabase service role secret key |
@@ -309,7 +309,7 @@ Upload a file to a Supabase storage bucket
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `message` | string | Operation status message | | `message` | string | Operation status message |
| `results` | object | Upload result including file path and metadata | | `results` | object | Upload result including file path, bucket, and public URL |
### `supabase_storage_download` ### `supabase_storage_download`

View File

@@ -17,7 +17,7 @@ Los servidores MCP agrupan tus herramientas de flujo de trabajo. Créalos y gest
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Navega a **Configuración → MCP implementados** 1. Navega a **Configuración → Servidores MCP**
2. Haz clic en **Crear servidor** 2. Haz clic en **Crear servidor**
3. Introduce un nombre y una descripción opcional 3. Introduce un nombre y una descripción opcional
4. Copia la URL del servidor para usarla en tus clientes MCP 4. Copia la URL del servidor para usarla en tus clientes MCP
@@ -79,7 +79,7 @@ Incluye tu encabezado de clave API (`X-API-Key`) para acceso autenticado al usar
## Gestión del servidor ## Gestión del servidor
Desde la vista de detalles del servidor en **Configuración → MCP implementados**, puedes: Desde la vista de detalles del servidor en **Configuración → Servidores MCP**, puedes:
- **Ver herramientas**: consulta todos los flujos de trabajo añadidos a un servidor - **Ver herramientas**: consulta todos los flujos de trabajo añadidos a un servidor
- **Copiar URL**: obtén la URL del servidor para clientes MCP - **Copiar URL**: obtén la URL del servidor para clientes MCP

View File

@@ -27,7 +27,7 @@ Los servidores MCP proporcionan colecciones de herramientas que tus agentes pued
</div> </div>
1. Navega a la configuración de tu espacio de trabajo 1. Navega a la configuración de tu espacio de trabajo
2. Ve a la sección **MCP implementados** 2. Ve a la sección **Servidores MCP**
3. Haz clic en **Añadir servidor MCP** 3. Haz clic en **Añadir servidor MCP**
4. Introduce los detalles de configuración del servidor 4. Introduce los detalles de configuración del servidor
5. Guarda la configuración 5. Guarda la configuración

View File

@@ -10,12 +10,20 @@ Despliega Sim en tu propia infraestructura con Docker o Kubernetes.
## Requisitos ## Requisitos
| Recurso | Mínimo | Recomendado | | Recurso | Pequeño | Estándar | Producción |
|----------|---------|-------------| |----------|---------|----------|------------|
| CPU | 2 núcleos | 4+ núcleos | | CPU | 2 núcleos | 4 núcleos | 8+ núcleos |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| Almacenamiento | 20 GB SSD | 50+ GB SSD | | Almacenamiento | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | Última versión | | Docker | 20.10+ | 20.10+ | Última versión |
**Pequeño**: Desarrollo, pruebas, usuario único (1-5 usuarios)
**Estándar**: Equipos (5-50 usuarios), cargas de trabajo moderadas
**Producción**: Equipos grandes (50+ usuarios), alta disponibilidad, ejecución intensiva de workflows
<Callout type="info">
Los requisitos de recursos están determinados por la ejecución de workflows (sandboxing isolated-vm), procesamiento de archivos (análisis de documentos en memoria) y operaciones vectoriales (pgvector). La memoria suele ser el factor limitante, no la CPU. La telemetría de producción muestra que la aplicación principal usa 4-8 GB en promedio con picos de hasta 12 GB bajo carga pesada.
</Callout>
## Inicio rápido ## Inicio rápido

View File

@@ -17,7 +17,7 @@ Les serveurs MCP regroupent vos outils de workflow. Créez-les et gérez-les dan
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Accédez à **Paramètres → MCP déployés** 1. Accédez à **Paramètres → Serveurs MCP**
2. Cliquez sur **Créer un serveur** 2. Cliquez sur **Créer un serveur**
3. Saisissez un nom et une description facultative 3. Saisissez un nom et une description facultative
4. Copiez l'URL du serveur pour l'utiliser dans vos clients MCP 4. Copiez l'URL du serveur pour l'utiliser dans vos clients MCP
@@ -79,7 +79,7 @@ Incluez votre en-tête de clé API (`X-API-Key`) pour un accès authentifié lor
## Gestion du serveur ## Gestion du serveur
Depuis la vue détaillée du serveur dans **Paramètres → MCP déployés**, vous pouvez : Depuis la vue détaillée du serveur dans **Paramètres → Serveurs MCP**, vous pouvez :
- **Voir les outils** : voir tous les workflows ajoutés à un serveur - **Voir les outils** : voir tous les workflows ajoutés à un serveur
- **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP - **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP

View File

@@ -28,7 +28,7 @@ Les serveurs MCP fournissent des collections d'outils que vos agents peuvent uti
</div> </div>
1. Accédez aux paramètres de votre espace de travail 1. Accédez aux paramètres de votre espace de travail
2. Allez dans la section **MCP déployés** 2. Allez dans la section **Serveurs MCP**
3. Cliquez sur **Ajouter un serveur MCP** 3. Cliquez sur **Ajouter un serveur MCP**
4. Saisissez les détails de configuration du serveur 4. Saisissez les détails de configuration du serveur
5. Enregistrez la configuration 5. Enregistrez la configuration

View File

@@ -10,12 +10,20 @@ Déployez Sim sur votre propre infrastructure avec Docker ou Kubernetes.
## Prérequis ## Prérequis
| Ressource | Minimum | Recommandé | | Ressource | Petit | Standard | Production |
|----------|---------|-------------| |----------|-------|----------|------------|
| CPU | 2 cœurs | 4+ cœurs | | CPU | 2 cœurs | 4 cœurs | 8+ cœurs |
| RAM | 12 Go | 16+ Go | | RAM | 12 Go | 16 Go | 32+ Go |
| Stockage | 20 Go SSD | 50+ Go SSD | | Stockage | 20 Go SSD | 50 Go SSD | 100+ Go SSD |
| Docker | 20.10+ | Dernière version | | Docker | 20.10+ | 20.10+ | Dernière version |
**Petit** : Développement, tests, utilisateur unique (1-5 utilisateurs)
**Standard** : Équipes (5-50 utilisateurs), charges de travail modérées
**Production** : Grandes équipes (50+ utilisateurs), haute disponibilité, exécution intensive de workflows
<Callout type="info">
Les besoins en ressources sont déterminés par l'exécution des workflows (sandboxing isolated-vm), le traitement des fichiers (analyse de documents en mémoire) et les opérations vectorielles (pgvector). La mémoire est généralement le facteur limitant, pas le CPU. La télémétrie de production montre que l'application principale utilise 4-8 Go en moyenne avec des pics jusqu'à 12 Go sous forte charge.
</Callout>
## Démarrage rapide ## Démarrage rapide

View File

@@ -16,7 +16,7 @@ MCPサーバーは、ワークフローツールをまとめてグループ化
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. **設定 → デプロイ済みMCP**に移動します 1. **設定 → MCP サーバー**に移動します
2. **サーバーを作成**をクリックします 2. **サーバーを作成**をクリックします
3. 名前とオプションの説明を入力します 3. 名前とオプションの説明を入力します
4. MCPクライアントで使用するためにサーバーURLをコピーします 4. MCPクライアントで使用するためにサーバーURLをコピーします
@@ -78,7 +78,7 @@ mcp-remoteまたは他のHTTPベースのMCPトランスポートを使用する
## サーバー管理 ## サーバー管理
**設定 → デプロイ済みMCP**のサーバー詳細ビューから、次のことができます: **設定 → MCP サーバー**のサーバー詳細ビューから、次のことができます:
- **ツールを表示**: サーバーに追加されたすべてのワークフローを確認 - **ツールを表示**: サーバーに追加されたすべてのワークフローを確認
- **URLをコピー**: MCPクライアント用のサーバーURLを取得 - **URLをコピー**: MCPクライアント用のサーバーURLを取得

View File

@@ -27,7 +27,7 @@ MCPサーバーはエージェントが使用できるツールのコレクシ
</div> </div>
1. ワークスペース設定に移動します 1. ワークスペース設定に移動します
2. **デプロイ済みMCP**セクションに移動します 2. **MCP サーバー**セクションに移動します
3. **MCPサーバーを追加**をクリックします 3. **MCPサーバーを追加**をクリックします
4. サーバー設定の詳細を入力します 4. サーバー設定の詳細を入力します
5. 設定を保存します 5. 設定を保存します

View File

@@ -10,12 +10,20 @@ DockerまたはKubernetesを使用して、自社のインフラストラクチ
## 要件 ## 要件
| リソース | 最小 | 推奨 | | リソース | スモール | スタンダード | プロダクション |
|----------|---------|-------------| |----------|---------|-------------|----------------|
| CPU | 2コア | 4+コア | | CPU | 2コア | 4コア | 8+コア |
| RAM | 12 GB | 16+ GB | | RAM | 12 GB | 16 GB | 32+ GB |
| ストレージ | 20 GB SSD | 50+ GB SSD | | ストレージ | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | 最新版 | | Docker | 20.10+ | 20.10+ | 最新版 |
**スモール**: 開発、テスト、シングルユーザー1-5ユーザー
**スタンダード**: チーム5-50ユーザー、中程度のワークロード
**プロダクション**: 大規模チーム50+ユーザー)、高可用性、高負荷ワークフロー実行
<Callout type="info">
リソース要件は、ワークフロー実行isolated-vmサンドボックス、ファイル処理メモリ内ドキュメント解析、ベクトル演算pgvectorによって決まります。CPUよりもメモリが制約要因となることが多いです。本番環境のテレメトリによると、メインアプリは平均4-8 GB、高負荷時は最大12 GBを使用します。
</Callout>
## クイックスタート ## クイックスタート

View File

@@ -16,7 +16,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. 进入 **设置 → 已部署的 MCPs** 1. 进入 **设置 → MCP 服务器**
2. 点击 **创建服务器** 2. 点击 **创建服务器**
3. 输入名称和可选描述 3. 输入名称和可选描述
4. 复制服务器 URL 以在你的 MCP 客户端中使用 4. 复制服务器 URL 以在你的 MCP 客户端中使用
@@ -78,7 +78,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
## 服务器管理 ## 服务器管理
在 **设置 → 已部署的 MCPs** 的服务器详情页,你可以: 在 **设置 → MCP 服务器** 的服务器详情页,你可以:
- **查看工具**:查看添加到服务器的所有工作流 - **查看工具**:查看添加到服务器的所有工作流
- **复制 URL**:获取 MCP 客户端的服务器 URL - **复制 URL**:获取 MCP 客户端的服务器 URL

View File

@@ -27,7 +27,7 @@ MCP 服务器提供工具集合,供您的代理使用。您可以在工作区
</div> </div>
1. 进入您的工作区设置 1. 进入您的工作区设置
2. 前往 **Deployed MCPs** 部分 2. 前往 **MCP Servers** 部分
3. 点击 **Add MCP Server** 3. 点击 **Add MCP Server**
4. 输入服务器配置信息 4. 输入服务器配置信息
5. 保存配置 5. 保存配置

View File

@@ -10,12 +10,20 @@ import { Callout } from 'fumadocs-ui/components/callout'
## 要求 ## 要求
| 资源 | 最低要求 | 推荐配置 | | 资源 | 小型 | 标准 | 生产环境 |
|----------|---------|-------------| |----------|------|------|----------|
| CPU | 2 核 | 4 核及以上 | | CPU | 2 核 | 4 核 | 8+ 核 |
| 内存 | 12 GB | 16 GB 及以上 | | 内存 | 12 GB | 16 GB | 32+ GB |
| 存储 | 20 GB SSD | 50 GB 及以上 SSD | | 存储 | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
| Docker | 20.10+ | 最新版本 | | Docker | 20.10+ | 20.10+ | 最新版本 |
**小型**: 开发、测试、单用户1-5 用户)
**标准**: 团队5-50 用户)、中等工作负载
**生产环境**: 大型团队50+ 用户)、高可用性、密集工作流执行
<Callout type="info">
资源需求由工作流执行isolated-vm 沙箱、文件处理内存中文档解析和向量运算pgvector决定。内存通常是限制因素而不是 CPU。生产遥测数据显示主应用平均使用 4-8 GB高负载时峰值可达 12 GB。
</Callout>
## 快速开始 ## 快速开始

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -8,6 +8,7 @@ import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
import { import {
createEnvVarPattern, createEnvVarPattern,
createWorkflowVariablePattern, createWorkflowVariablePattern,
@@ -387,7 +388,12 @@ function resolveWorkflowVariables(
if (type === 'number') { if (type === 'number') {
variableValue = Number(variableValue) variableValue = Number(variableValue)
} else if (type === 'boolean') { } else if (type === 'boolean') {
variableValue = variableValue === 'true' || variableValue === true if (typeof variableValue === 'boolean') {
// Already a boolean, keep as-is
} else {
const normalized = String(variableValue).toLowerCase().trim()
variableValue = normalized === 'true'
}
} else if (type === 'json' && typeof variableValue === 'string') { } else if (type === 'json' && typeof variableValue === 'string') {
try { try {
variableValue = JSON.parse(variableValue) variableValue = JSON.parse(variableValue)
@@ -687,11 +693,7 @@ export async function POST(req: NextRequest) {
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n` prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++ prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) { for (const [k, v] of Object.entries(contextVariables)) {
if (v === undefined) { prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n`
prologue += `const ${k} = undefined;\n`
} else {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
}
prologueLineCount++ prologueLineCount++
} }
@@ -762,11 +764,7 @@ export async function POST(req: NextRequest) {
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n` prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++ prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) { for (const [k, v] of Object.entries(contextVariables)) {
if (v === undefined) { prologue += `${k} = ${formatLiteralForCode(v, 'python')}\n`
prologue += `${k} = None\n`
} else {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
}
prologueLineCount++ prologueLineCount++
} }
const wrapped = [ const wrapped = [

View File

@@ -16,6 +16,10 @@ mockKnowledgeSchemas()
mockDrizzleOrm() mockDrizzleOrm()
mockConsoleLogger() mockConsoleLogger()
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue({ role: 'owner' }),
}))
describe('Knowledge Base API Route', () => { describe('Knowledge Base API Route', () => {
const mockAuth$ = mockAuth() const mockAuth$ = mockAuth()
@@ -86,6 +90,7 @@ describe('Knowledge Base API Route', () => {
const validKnowledgeBaseData = { const validKnowledgeBaseData = {
name: 'Test Knowledge Base', name: 'Test Knowledge Base',
description: 'Test description', description: 'Test description',
workspaceId: 'test-workspace-id',
chunkingConfig: { chunkingConfig: {
maxSize: 1024, maxSize: 1024,
minSize: 100, minSize: 100,
@@ -133,11 +138,25 @@ describe('Knowledge Base API Route', () => {
expect(data.details).toBeDefined() expect(data.details).toBeDefined()
}) })
it('should require workspaceId', async () => {
mockAuth$.mockAuthenticatedUser()
const req = createMockRequest('POST', { name: 'Test KB' })
const { POST } = await import('@/app/api/knowledge/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request data')
expect(data.details).toBeDefined()
})
it('should validate chunking config constraints', async () => { it('should validate chunking config constraints', async () => {
mockAuth$.mockAuthenticatedUser() mockAuth$.mockAuthenticatedUser()
const invalidData = { const invalidData = {
name: 'Test KB', name: 'Test KB',
workspaceId: 'test-workspace-id',
chunkingConfig: { chunkingConfig: {
maxSize: 100, // 100 tokens = 400 characters maxSize: 100, // 100 tokens = 400 characters
minSize: 500, // Invalid: minSize (500 chars) > maxSize (400 chars) minSize: 500, // Invalid: minSize (500 chars) > maxSize (400 chars)
@@ -157,7 +176,7 @@ describe('Knowledge Base API Route', () => {
it('should use default values for optional fields', async () => { it('should use default values for optional fields', async () => {
mockAuth$.mockAuthenticatedUser() mockAuth$.mockAuthenticatedUser()
const minimalData = { name: 'Test KB' } const minimalData = { name: 'Test KB', workspaceId: 'test-workspace-id' }
const req = createMockRequest('POST', minimalData) const req = createMockRequest('POST', minimalData)
const { POST } = await import('@/app/api/knowledge/route') const { POST } = await import('@/app/api/knowledge/route')
const response = await POST(req) const response = await POST(req)

View File

@@ -19,7 +19,7 @@ const logger = createLogger('KnowledgeBaseAPI')
const CreateKnowledgeBaseSchema = z.object({ const CreateKnowledgeBaseSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
description: z.string().optional(), description: z.string().optional(),
workspaceId: z.string().optional(), workspaceId: z.string().min(1, 'Workspace ID is required'),
embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'),
embeddingDimension: z.literal(1536).default(1536), embeddingDimension: z.literal(1536).default(1536),
chunkingConfig: z chunkingConfig: z

View File

@@ -408,6 +408,7 @@ describe('Knowledge Search Utils', () => {
input: ['test query'], input: ['test query'],
model: 'text-embedding-3-small', model: 'text-embedding-3-small',
encoding_format: 'float', encoding_format: 'float',
dimensions: 1536,
}), }),
}) })
) )

View File

@@ -1,204 +0,0 @@
import { db } from '@sim/db'
import { member, permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
const logger = createLogger('OrganizationWorkspacesAPI')
/**
* GET /api/organizations/[id]/workspaces
* Get workspaces related to the organization with optional filtering
* Query parameters:
* - ?available=true - Only workspaces where user can invite others (admin permissions)
* - ?member=userId - Workspaces where specific member has access
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const availableOnly = url.searchParams.get('available') === 'true'
const memberId = url.searchParams.get('member')
// Verify user is a member of this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{
error: 'Forbidden - Not a member of this organization',
},
{ status: 403 }
)
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
if (availableOnly) {
// Get workspaces where user has admin permissions (can invite others)
const availableWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
isOwner: eq(workspace.ownerId, session.user.id),
permissionType: permissions.permissionType,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, session.user.id)
)
)
.where(
or(
// User owns the workspace
eq(workspace.ownerId, session.user.id),
// User has admin permission on the workspace
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.permissionType, 'admin')
)
)
)
// Filter and format the results
const workspacesWithInvitePermission = availableWorkspaces
.filter((workspace) => {
// Include if user owns the workspace OR has admin permission
return workspace.isOwner || workspace.permissionType === 'admin'
})
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
canInvite: true, // All returned workspaces have invite permission
createdAt: workspace.createdAt,
}))
logger.info('Retrieved available workspaces for organization member', {
organizationId,
userId: session.user.id,
workspaceCount: workspacesWithInvitePermission.length,
})
return NextResponse.json({
success: true,
data: {
workspaces: workspacesWithInvitePermission,
totalCount: workspacesWithInvitePermission.length,
filter: 'available',
},
})
}
if (memberId && hasAdminAccess) {
// Get workspaces where specific member has access (admin only)
const memberWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
isOwner: eq(workspace.ownerId, memberId),
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, memberId)
)
)
.where(
or(
// Member owns the workspace
eq(workspace.ownerId, memberId),
// Member has permissions on the workspace
and(eq(permissions.userId, memberId), eq(permissions.entityType, 'workspace'))
)
)
const formattedWorkspaces = memberWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
permission: workspace.permissionType,
joinedAt: workspace.createdAt,
createdAt: workspace.createdAt,
}))
return NextResponse.json({
success: true,
data: {
workspaces: formattedWorkspaces,
totalCount: formattedWorkspaces.length,
filter: 'member',
memberId,
},
})
}
// Default: Get all workspaces (basic info only for regular members)
if (!hasAdminAccess) {
return NextResponse.json({
success: true,
data: {
workspaces: [],
totalCount: 0,
message: 'Workspace access information is only available to organization admins',
},
})
}
// For admins: Get summary of all workspaces
const allWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
ownerName: user.name,
})
.from(workspace)
.leftJoin(user, eq(workspace.ownerId, user.id))
return NextResponse.json({
success: true,
data: {
workspaces: allWorkspaces,
totalCount: allWorkspaces.length,
filter: 'all',
},
userRole,
hasAdminAccess,
})
} catch (error) {
logger.error('Failed to get organization workspaces', { error })
return NextResponse.json(
{
error: 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,257 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('SupabaseStorageUploadAPI')
const SupabaseStorageUploadSchema = z.object({
projectId: z.string().min(1, 'Project ID is required'),
apiKey: z.string().min(1, 'API key is required'),
bucket: z.string().min(1, 'Bucket name is required'),
fileName: z.string().min(1, 'File name is required'),
path: z.string().optional().nullable(),
fileData: z.any(),
contentType: z.string().optional().nullable(),
upsert: z.boolean().optional().default(false),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated Supabase storage upload request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = SupabaseStorageUploadSchema.parse(body)
const fileData = validatedData.fileData
const isStringInput = typeof fileData === 'string'
logger.info(`[${requestId}] Uploading to Supabase Storage`, {
bucket: validatedData.bucket,
fileName: validatedData.fileName,
path: validatedData.path,
fileDataType: isStringInput ? 'string' : 'object',
})
if (!fileData) {
return NextResponse.json(
{
success: false,
error: 'fileData is required',
},
{ status: 400 }
)
}
let uploadBody: Buffer
let uploadContentType: string | undefined
if (isStringInput) {
let content = fileData as string
const dataUrlMatch = content.match(/^data:([^;]+);base64,(.+)$/s)
if (dataUrlMatch) {
const [, mimeType, base64Data] = dataUrlMatch
content = base64Data
if (!validatedData.contentType) {
uploadContentType = mimeType
}
logger.info(`[${requestId}] Extracted base64 from data URL (MIME: ${mimeType})`)
}
const cleanedContent = content.replace(/[\s\r\n]/g, '')
const isLikelyBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(cleanedContent)
if (isLikelyBase64 && cleanedContent.length >= 4) {
try {
uploadBody = Buffer.from(cleanedContent, 'base64')
const expectedMinSize = Math.floor(cleanedContent.length * 0.7)
const expectedMaxSize = Math.ceil(cleanedContent.length * 0.8)
if (
uploadBody.length >= expectedMinSize &&
uploadBody.length <= expectedMaxSize &&
uploadBody.length > 0
) {
logger.info(
`[${requestId}] Decoded base64 content: ${cleanedContent.length} chars -> ${uploadBody.length} bytes`
)
} else {
const reEncoded = uploadBody.toString('base64')
if (reEncoded !== cleanedContent) {
logger.info(
`[${requestId}] Content looked like base64 but re-encoding didn't match, using as plain text`
)
uploadBody = Buffer.from(content, 'utf-8')
} else {
logger.info(
`[${requestId}] Decoded base64 content (verified): ${uploadBody.length} bytes`
)
}
}
} catch (decodeError) {
logger.info(
`[${requestId}] Failed to decode as base64, using as plain text: ${decodeError}`
)
uploadBody = Buffer.from(content, 'utf-8')
}
} else {
uploadBody = Buffer.from(content, 'utf-8')
logger.info(`[${requestId}] Using content as plain text (${uploadBody.length} bytes)`)
}
uploadContentType =
uploadContentType || validatedData.contentType || 'application/octet-stream'
} else {
const rawFile = fileData
logger.info(`[${requestId}] Processing file object: ${rawFile.name || 'unknown'}`)
let userFile
try {
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to process file',
},
{ status: 400 }
)
}
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
uploadBody = buffer
uploadContentType = validatedData.contentType || userFile.type || 'application/octet-stream'
}
let fullPath = validatedData.fileName
if (validatedData.path) {
const folderPath = validatedData.path.endsWith('/')
? validatedData.path
: `${validatedData.path}/`
fullPath = `${folderPath}${validatedData.fileName}`
}
const supabaseUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}`
const headers: Record<string, string> = {
apikey: validatedData.apiKey,
Authorization: `Bearer ${validatedData.apiKey}`,
'Content-Type': uploadContentType,
}
if (validatedData.upsert) {
headers['x-upsert'] = 'true'
}
logger.info(`[${requestId}] Sending to Supabase: ${supabaseUrl}`, {
contentType: uploadContentType,
bodySize: uploadBody.length,
upsert: validatedData.upsert,
})
const response = await fetch(supabaseUrl, {
method: 'POST',
headers,
body: new Uint8Array(uploadBody),
})
if (!response.ok) {
const errorText = await response.text()
let errorData
try {
errorData = JSON.parse(errorText)
} catch {
errorData = { message: errorText }
}
logger.error(`[${requestId}] Supabase Storage upload failed:`, {
status: response.status,
statusText: response.statusText,
error: errorData,
})
return NextResponse.json(
{
success: false,
error: errorData.message || errorData.error || `Upload failed: ${response.statusText}`,
details: errorData,
},
{ status: response.status }
)
}
const result = await response.json()
logger.info(`[${requestId}] File uploaded successfully to Supabase Storage`, {
bucket: validatedData.bucket,
path: fullPath,
})
const publicUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}`
return NextResponse.json({
success: true,
output: {
message: 'Successfully uploaded file to storage',
results: {
...result,
path: fullPath,
bucket: validatedData.bucket,
publicUrl,
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error uploading to Supabase Storage:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor' import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates' import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
@@ -330,7 +330,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
try { try {
return ( return (
<WorkflowPreview <PreviewWorkflow
workflowState={template.state} workflowState={template.state}
height='100%' height='100%'
width='100%' width='100%'

View File

@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge' import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates' import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -200,13 +200,14 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]' className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
> >
{normalizedState && isInView ? ( {normalizedState && isInView ? (
<WorkflowPreview <PreviewWorkflow
workflowState={normalizedState} workflowState={normalizedState}
height={180} height={180}
width='100%' width='100%'
isPannable={false} isPannable={false}
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
lightweight
/> />
) : ( ) : (
<div className='h-full w-full bg-[var(--surface-4)]' /> <div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react' import { AlertCircle, Loader2 } from 'lucide-react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { import {
@@ -13,13 +13,8 @@ import {
PopoverContent, PopoverContent,
PopoverItem, PopoverItem,
} from '@/components/emcn' } from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview'
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs' import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -32,13 +27,6 @@ interface TraceSpan {
children?: TraceSpan[] children?: TraceSpan[]
} }
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
}
interface MigratedWorkflowState extends WorkflowState { interface MigratedWorkflowState extends WorkflowState {
_migrated: true _migrated: true
_note?: string _note?: string
@@ -70,99 +58,29 @@ export function ExecutionSnapshot({
onClose = () => {}, onClose = () => {},
}: ExecutionSnapshotProps) { }: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId) const { data, isLoading, error } = useExecutionSnapshot(executionId)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const autoSelectedForExecutionRef = useRef<string | null>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => { const closeMenu = useCallback(() => {
setIsMenuOpen(false) setIsMenuOpen(false)
setContextMenuBlockId(null)
}, []) }, [])
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => { const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setContextMenuBlockId(null)
setMenuPosition({ x: e.clientX, y: e.clientY }) setMenuPosition({ x: e.clientX, y: e.clientY })
setIsMenuOpen(true) setIsMenuOpen(true)
}, []) }, [])
const handleNodeContextMenu = useCallback(
(blockId: string, mousePosition: { x: number; y: number }) => {
setContextMenuBlockId(blockId)
setMenuPosition(mousePosition)
setIsMenuOpen(true)
},
[]
)
const handleCopyExecutionId = useCallback(() => { const handleCopyExecutionId = useCallback(() => {
navigator.clipboard.writeText(executionId) navigator.clipboard.writeText(executionId)
closeMenu() closeMenu()
}, [executionId, closeMenu]) }, [executionId, closeMenu])
const handleOpenDetails = useCallback(() => {
if (contextMenuBlockId) {
setPinnedBlockId(contextMenuBlockId)
}
closeMenu()
}, [contextMenuBlockId, closeMenu])
const blockExecutions = useMemo(() => {
if (!traceSpans || !Array.isArray(traceSpans)) return {}
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of spans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
}
}
}
return blockExecutionMap
}, [traceSpans])
const workflowState = data?.workflowState as WorkflowState | undefined const workflowState = data?.workflowState as WorkflowState | undefined
// Auto-select the leftmost block once when data loads for a new executionId
useEffect(() => {
if (
workflowState &&
!isMigratedWorkflowState(workflowState) &&
autoSelectedForExecutionRef.current !== executionId
) {
autoSelectedForExecutionRef.current = executionId
const leftmostId = getLeftmostBlockId(workflowState)
setPinnedBlockId(leftmostId)
}
}, [executionId, workflowState])
const renderContent = () => { const renderContent = () => {
if (isLoading) { if (isLoading) {
return ( return (
@@ -226,44 +144,17 @@ export function ExecutionSnapshot({
} }
return ( return (
<div <Preview
style={{ height, width }} key={executionId}
className={cn(
'flex overflow-hidden',
!isModal && 'rounded-[4px] border border-[var(--border)]',
className
)}
>
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
<WorkflowPreview
workflowState={workflowState} workflowState={workflowState}
isPannable={true} traceSpans={traceSpans}
defaultPosition={{ x: 0, y: 0 }} className={className}
defaultZoom={0.8} height={height}
onNodeClick={(blockId) => { width={width}
setPinnedBlockId(blockId) onCanvasContextMenu={handleCanvasContextMenu}
}} showBorder={!isModal}
onNodeContextMenu={handleNodeContextMenu} autoSelectLeftmost
onPaneClick={() => setPinnedBlockId(null)}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
/> />
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode
onClose={() => setPinnedBlockId(null)}
/>
)}
</div>
) )
} }
@@ -287,9 +178,6 @@ export function ExecutionSnapshot({
}} }}
/> />
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}> <PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{contextMenuBlockId && (
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
)}
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem> <PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
</PopoverContent> </PopoverContent>
</Popover>, </Popover>,
@@ -304,7 +192,6 @@ export function ExecutionSnapshot({
open={isOpen} open={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setPinnedBlockId(null)
onClose() onClose()
} }
}} }}

View File

@@ -3,7 +3,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge' import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates' import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -206,7 +206,7 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]' className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
> >
{normalizedState && isInView ? ( {normalizedState && isInView ? (
<WorkflowPreview <PreviewWorkflow
workflowState={normalizedState} workflowState={normalizedState}
height={180} height={180}
width='100%' width='100%'
@@ -214,6 +214,7 @@ function TemplateCardInner({
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
cursorStyle='pointer' cursorStyle='pointer'
lightweight
/> />
) : ( ) : (
<div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' /> <div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />

View File

@@ -25,18 +25,277 @@ function extractFieldValue(rawValue: unknown): string | undefined {
return undefined return undefined
} }
/** type EmbedInfo = {
* Extract YouTube video ID from various YouTube URL formats url: string
*/ type: 'iframe' | 'video' | 'audio'
function getYouTubeVideoId(url: string): string | null { aspectRatio?: string
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
} }
const EMBED_SCALE = 0.78
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
function getTwitchParent(): string {
return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
}
/**
* Get embed info for supported media platforms
*/
function getEmbedInfo(url: string): EmbedInfo | null {
const youtubeMatch = url.match(
/(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
)
if (youtubeMatch) {
return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' }
}
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
if (vimeoMatch) {
return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
}
const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
if (dailymotionMatch) {
return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
}
const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
if (twitchVideoMatch) {
return {
url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
return {
url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
if (streamableMatch) {
return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
}
const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
if (wistiaMatch) {
return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
}
const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
if (tiktokMatch) {
return {
url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
type: 'iframe',
aspectRatio: '9/16',
}
}
const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
if (soundcloudMatch) {
return {
url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
type: 'iframe',
aspectRatio: '3/2',
}
}
const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
if (spotifyTrackMatch) {
return {
url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
type: 'iframe',
aspectRatio: '3.7/1',
}
}
const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
if (spotifyAlbumMatch) {
return {
url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
if (spotifyPlaylistMatch) {
return {
url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
if (spotifyEpisodeMatch) {
return {
url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
type: 'iframe',
aspectRatio: '2.5/1',
}
}
const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
if (spotifyShowMatch) {
return {
url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
type: 'iframe',
aspectRatio: '3.7/1',
}
}
const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
if (appleMusicSongMatch) {
const [, country, songId] = appleMusicSongMatch
return {
url: `https://embed.music.apple.com/${country}/song/${songId}`,
type: 'iframe',
aspectRatio: '3/2',
}
}
const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
if (appleMusicAlbumMatch) {
const [, country, albumId] = appleMusicAlbumMatch
return {
url: `https://embed.music.apple.com/${country}/album/${albumId}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const appleMusicPlaylistMatch = url.match(
/music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
)
if (appleMusicPlaylistMatch) {
const [, country, playlistId] = appleMusicPlaylistMatch
return {
url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
if (loomMatch) {
return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
}
const facebookVideoMatch =
url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
if (facebookVideoMatch) {
return {
url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
type: 'iframe',
}
}
const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
if (instagramReelMatch) {
return {
url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
type: 'iframe',
aspectRatio: '9/16',
}
}
const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
if (instagramPostMatch) {
return {
url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
type: 'iframe',
aspectRatio: '4/5',
}
}
const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
if (twitterMatch) {
return {
url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
type: 'iframe',
aspectRatio: '3/4',
}
}
const rumbleMatch =
url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
if (rumbleMatch) {
return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
}
const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
if (bilibiliMatch) {
return {
url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
type: 'iframe',
}
}
const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
if (vidyardMatch) {
return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
}
const cfStreamMatch =
url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
if (cfStreamMatch) {
return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
}
const twitchClipMatch =
url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
if (twitchClipMatch) {
return {
url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
if (mixcloudMatch) {
return {
url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
type: 'iframe',
aspectRatio: '2/1',
}
}
const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
if (googleDriveMatch) {
return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
}
if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
const directUrl = url
.replace('www.dropbox.com', 'dl.dropboxusercontent.com')
.replace('?dl=0', '')
return { url: directUrl, type: 'video' }
}
const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
if (tenorMatch) {
return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
}
const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
if (giphyMatch) {
return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
}
if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
return { url, type: 'video' }
}
if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
return { url, type: 'audio' }
}
return null return null
} }
@@ -108,30 +367,58 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
) )
}, },
a: ({ href, children }: any) => { a: ({ href, children }: any) => {
const videoId = href ? getYouTubeVideoId(href) : null const embedInfo = href ? getEmbedInfo(href) : null
if (videoId) { if (embedInfo) {
return ( return (
<span className='inline'> <span className='my-2 block w-full'>
<a <a
href={href} href={href}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-[var(--brand-secondary)] underline-offset-2 hover:underline' className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
> >
{children} {children}
</a> </a>
<span className='mt-1.5 block overflow-hidden rounded-md'> <span className='block w-full overflow-hidden rounded-md'>
{embedInfo.type === 'iframe' && (
<span
className='block overflow-hidden'
style={{
width: '100%',
aspectRatio: embedInfo.aspectRatio || '16/9',
}}
>
<iframe <iframe
src={`https://www.youtube.com/embed/${videoId}`} src={embedInfo.url}
title='YouTube video' title='Media'
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
loading='lazy' loading='lazy'
referrerPolicy='strict-origin-when-cross-origin' className='origin-top-left'
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups' style={{
className='aspect-video w-full' width: EMBED_INVERSE_SCALE,
height: EMBED_INVERSE_SCALE,
transform: `scale(${EMBED_SCALE})`,
}}
/> />
</span> </span>
)}
{embedInfo.type === 'video' && (
<video
src={embedInfo.url}
controls
preload='metadata'
className='aspect-video w-full'
>
<track kind='captions' src='' default />
</video>
)}
{embedInfo.type === 'audio' && (
<audio src={embedInfo.url} controls preload='metadata' className='w-full'>
<track kind='captions' src='' default />
</audio>
)}
</span>
</span> </span>
) )
} }
@@ -140,7 +427,7 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
href={href} href={href}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-[var(--brand-secondary)] underline-offset-2 hover:underline' className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
> >
{children} {children}
</a> </a>
@@ -159,6 +446,26 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
{children} {children}
</blockquote> </blockquote>
), ),
table: ({ children }: any) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='w-full border-collapse text-xs'>{children}</table>
</div>
),
thead: ({ children }: any) => (
<thead className='border-[var(--border)] border-b'>{children}</thead>
),
tbody: ({ children }: any) => <tbody>{children}</tbody>,
tr: ({ children }: any) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: any) => (
<th className='px-2 py-1 text-left font-semibold text-[var(--text-primary)]'>
{children}
</th>
),
td: ({ children }: any) => (
<td className='px-2 py-1 text-[var(--text-secondary)]'>{children}</td>
),
}} }}
> >
{content} {content}
@@ -171,7 +478,7 @@ export const NoteBlock = memo(function NoteBlock({
data, data,
selected, selected,
}: NodeProps<NoteBlockNodeData>) { }: NodeProps<NoteBlockNodeData>) {
const { type, config, name } = data const { type, name } = data
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({ const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
blockId: id, blockId: id,
@@ -248,8 +555,8 @@ export const NoteBlock = memo(function NoteBlock({
</div> </div>
</div> </div>
<div className='relative p-[8px]'> <div className='relative overflow-hidden p-[8px]'>
<div className='relative break-words'> <div className='relative max-w-full break-all'>
{isEmpty ? ( {isEmpty ? (
<p className='text-[#868686] text-sm'>Add note...</p> <p className='text-[#868686] text-sm'>Add note...</p>
) : ( ) : (

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { import {
Button, Button,
@@ -17,11 +17,7 @@ import {
} from '@/components/emcn' } from '@/components/emcn'
import { Skeleton } from '@/components/ui' import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows' import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { Versions } from './components' import { Versions } from './components'
@@ -59,8 +55,6 @@ export function GeneralDeploy({
const [showLoadDialog, setShowLoadDialog] = useState(false) const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false) const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false) const [showExpandedPreview, setShowExpandedPreview] = useState(false)
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
const hasAutoSelectedRef = useRef(false)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null) const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null) const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
@@ -135,19 +129,6 @@ export function GeneralDeploy({
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0 const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
// Auto-select the leftmost block once when expanded preview opens
useEffect(() => {
if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true
const leftmostId = getLeftmostBlockId(workflowToShow)
setExpandedSelectedBlockId(leftmostId)
}
// Reset when modal closes
if (!showExpandedPreview) {
hasAutoSelectedRef.current = false
}
}, [showExpandedPreview, workflowToShow])
if (showLoadingSkeleton) { if (showLoadingSkeleton) {
return ( return (
<div className='space-y-[12px]'> <div className='space-y-[12px]'>
@@ -205,7 +186,7 @@ export function GeneralDeploy({
{workflowToShow ? ( {workflowToShow ? (
<> <>
<div className='[&_*]:!cursor-default h-full w-full cursor-default'> <div className='[&_*]:!cursor-default h-full w-full cursor-default'>
<WorkflowPreview <PreviewWorkflow
workflowState={workflowToShow} workflowState={workflowToShow}
height='100%' height='100%'
width='100%' width='100%'
@@ -306,46 +287,15 @@ export function GeneralDeploy({
</Modal> </Modal>
{workflowToShow && ( {workflowToShow && (
<Modal <Modal open={showExpandedPreview} onOpenChange={setShowExpandedPreview}>
open={showExpandedPreview}
onOpenChange={(open) => {
if (!open) {
setExpandedSelectedBlockId(null)
}
setShowExpandedPreview(open)
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'> <ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader> <ModalHeader>
{previewMode === 'selected' && selectedVersionInfo {previewMode === 'selected' && selectedVersionInfo
? selectedVersionInfo.name || `v${selectedVersion}` ? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'} : 'Live Workflow'}
</ModalHeader> </ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'> <ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>
<div className='flex h-full w-full overflow-hidden'> <Preview workflowState={workflowToShow} autoSelectLeftmost />
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowToShow}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
onNodeClick={(blockId) => {
setExpandedSelectedBlockId(blockId)
}}
onPaneClick={() => setExpandedSelectedBlockId(null)}
selectedBlockId={expandedSelectedBlockId}
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<PreviewEditor
block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops}
parallels={workflowToShow.parallels}
onClose={() => setExpandedSelectedBlockId(null)}
/>
)}
</div>
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@@ -435,7 +435,7 @@ export function McpDeploy({
return ( return (
<div className='flex h-full flex-col items-center justify-center gap-3'> <div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'> <p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings Deployed MCPs first. Create an MCP Server in Settings MCP Servers first.
</p> </p>
<Button <Button
variant='tertiary' variant='tertiary'

View File

@@ -19,7 +19,7 @@ import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client' import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og' import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile' import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import { import {
useCreateTemplate, useCreateTemplate,
@@ -439,13 +439,14 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
}} }}
aria-hidden='true' aria-hidden='true'
> >
<WorkflowPreview <PreviewWorkflow
workflowState={workflowState} workflowState={workflowState}
height='100%' height='100%'
width='100%' width='100%'
isPannable={false} isPannable={false}
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
lightweight
/> />
</div> </div>
) )
@@ -477,7 +478,7 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
} }
return ( return (
<WorkflowPreview <PreviewWorkflow
key={`template-preview-${existingTemplate.id}`} key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState} workflowState={workflowState}
height='100%' height='100%'

View File

@@ -39,6 +39,8 @@ import { normalizeName } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection' import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars' import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useCodeUndoRedo } from '@/hooks/use-code-undo-redo'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Code') const logger = createLogger('Code')
@@ -212,7 +214,6 @@ export const Code = memo(function Code({
const handleStreamStartRef = useRef<() => void>(() => {}) const handleStreamStartRef = useRef<() => void>(() => {})
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {}) const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {}) const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
const hasEditedSinceFocusRef = useRef(false)
const codeRef = useRef(code) const codeRef = useRef(code)
codeRef.current = code codeRef.current = code
@@ -220,8 +221,12 @@ export const Code = memo(function Code({
const emitTagSelection = useTagSelection(blockId, subBlockId) const emitTagSelection = useTagSelection(blockId, subBlockId)
const [languageValue] = useSubBlockValue<string>(blockId, 'language') const [languageValue] = useSubBlockValue<string>(blockId, 'language')
const availableEnvVars = useAvailableEnvVarKeys(workspaceId) const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const blockType = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
)
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
const isFunctionCode = blockType === 'function' && subBlockId === 'code'
const trimmedCode = code.trim() const trimmedCode = code.trim()
const containsReferencePlaceholders = const containsReferencePlaceholders =
@@ -296,6 +301,15 @@ export const Code = memo(function Code({
const updatePromptValue = wandHook?.updatePromptValue || (() => {}) const updatePromptValue = wandHook?.updatePromptValue || (() => {})
const cancelGeneration = wandHook?.cancelGeneration || (() => {}) const cancelGeneration = wandHook?.cancelGeneration || (() => {})
const { recordChange, recordReplace, flushPending, startSession, undo, redo } = useCodeUndoRedo({
blockId,
subBlockId,
value: code,
enabled: isFunctionCode,
isReadOnly: readOnly || disabled || isPreview,
isStreaming: isAiStreaming,
})
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, {
isStreaming: isAiStreaming, isStreaming: isAiStreaming,
onStreamingEnd: () => { onStreamingEnd: () => {
@@ -347,9 +361,10 @@ export const Code = memo(function Code({
setCode(generatedCode) setCode(generatedCode)
if (!isPreview && !disabled) { if (!isPreview && !disabled) {
setStoreValue(generatedCode) setStoreValue(generatedCode)
recordReplace(generatedCode)
} }
} }
}, [isPreview, disabled, setStoreValue]) }, [disabled, isPreview, recordReplace, setStoreValue])
useEffect(() => { useEffect(() => {
if (!editorRef.current) return if (!editorRef.current) return
@@ -492,7 +507,7 @@ export const Code = memo(function Code({
setCode(newValue) setCode(newValue)
setStoreValue(newValue) setStoreValue(newValue)
hasEditedSinceFocusRef.current = true recordChange(newValue)
const newCursorPosition = dropPosition + 1 const newCursorPosition = dropPosition + 1
setCursorPosition(newCursorPosition) setCursorPosition(newCursorPosition)
@@ -521,7 +536,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) { if (!isPreview && !readOnly) {
setCode(newValue) setCode(newValue)
emitTagSelection(newValue) emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true recordChange(newValue)
} }
setShowTags(false) setShowTags(false)
setActiveSourceBlockId(null) setActiveSourceBlockId(null)
@@ -539,7 +554,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) { if (!isPreview && !readOnly) {
setCode(newValue) setCode(newValue)
emitTagSelection(newValue) emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true recordChange(newValue)
} }
setShowEnvVars(false) setShowEnvVars(false)
@@ -625,9 +640,9 @@ export const Code = memo(function Code({
const handleValueChange = useCallback( const handleValueChange = useCallback(
(newCode: string) => { (newCode: string) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) { if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode) setCode(newCode)
setStoreValue(newCode) setStoreValue(newCode)
recordChange(newCode)
const textarea = editorRef.current?.querySelector('textarea') const textarea = editorRef.current?.querySelector('textarea')
if (textarea) { if (textarea) {
@@ -646,7 +661,7 @@ export const Code = memo(function Code({
} }
} }
}, },
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue] [isAiStreaming, isPreview, disabled, readOnly, recordChange, setStoreValue]
) )
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -657,21 +672,39 @@ export const Code = memo(function Code({
} }
if (isAiStreaming) { if (isAiStreaming) {
e.preventDefault() e.preventDefault()
return
} }
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) { if (!isFunctionCode) return
const isUndo = (e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && !e.shiftKey
const isRedo =
((e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && e.shiftKey) ||
(e.key === 'y' && (e.metaKey || e.ctrlKey))
if (isUndo) {
e.preventDefault() e.preventDefault()
e.stopPropagation()
undo()
return
}
if (isRedo) {
e.preventDefault()
e.stopPropagation()
redo()
} }
}, },
[isAiStreaming] [isAiStreaming, isFunctionCode, redo, undo]
) )
const handleEditorFocus = useCallback(() => { const handleEditorFocus = useCallback(() => {
hasEditedSinceFocusRef.current = false startSession(codeRef.current)
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') { if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
setShowTags(true) setShowTags(true)
setCursorPosition(0) setCursorPosition(0)
} }
}, [isPreview, disabled, readOnly]) }, [disabled, isPreview, readOnly, startSession])
const handleEditorBlur = useCallback(() => {
flushPending()
}, [flushPending])
/** /**
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line. * Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
@@ -791,6 +824,7 @@ export const Code = memo(function Code({
onValueChange={handleValueChange} onValueChange={handleValueChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleEditorFocus} onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
highlight={highlightCode} highlight={highlightCode}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })} {...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
/> />

View File

@@ -338,6 +338,11 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
const configEqual = const configEqual =
prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type
const canonicalToggleEqual =
!!prevProps.canonicalToggle === !!nextProps.canonicalToggle &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
return ( return (
prevProps.blockId === nextProps.blockId && prevProps.blockId === nextProps.blockId &&
configEqual && configEqual &&
@@ -346,8 +351,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.disabled === nextProps.disabled && prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus && prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview && prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode && canonicalToggleEqual
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
) )
} }

View File

@@ -38,7 +38,7 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils' import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types' import type { SubBlockType } from '@/blocks/types'
import { useWorkflowState } from '@/hooks/queries/workflows' import { useWorkflowState } from '@/hooks/queries/workflows'
@@ -458,7 +458,7 @@ export function Editor() {
) : childWorkflowState ? ( ) : childWorkflowState ? (
<> <>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'> <div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<WorkflowPreview <PreviewWorkflow
workflowState={childWorkflowState} workflowState={childWorkflowState}
height={160} height={160}
width='100%' width='100%'
@@ -466,6 +466,7 @@ export function Editor() {
defaultZoom={0.6} defaultZoom={0.6}
fitPadding={0.15} fitPadding={0.15}
cursorStyle='grab' cursorStyle='grab'
lightweight
/> />
</div> </div>
<Tooltip.Root> <Tooltip.Root>

View File

@@ -6,6 +6,7 @@ import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useExecutionStore } from '@/stores/execution' import { useExecutionStore } from '@/stores/execution'
import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowDiffStore } from '@/stores/workflow-diff'
/** Extended edge props with optional handle identifiers */
interface WorkflowEdgeProps extends EdgeProps { interface WorkflowEdgeProps extends EdgeProps {
sourceHandle?: string | null sourceHandle?: string | null
targetHandle?: string | null targetHandle?: string | null
@@ -90,15 +91,17 @@ const WorkflowEdgeComponent = ({
if (edgeDiffStatus === 'deleted') { if (edgeDiffStatus === 'deleted') {
color = 'var(--text-error)' color = 'var(--text-error)'
opacity = 0.7 opacity = 0.7
} else if (isErrorEdge) {
color = 'var(--text-error)'
} else if (edgeDiffStatus === 'new') { } else if (edgeDiffStatus === 'new') {
color = 'var(--brand-tertiary-2)' color = 'var(--brand-tertiary-2)'
} else if (edgeRunStatus === 'success') { } else if (edgeRunStatus === 'success') {
// Use green for preview mode, default for canvas execution // Use green for preview mode, default for canvas execution
// This also applies to error edges that were taken (error path executed)
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)' color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
} else if (edgeRunStatus === 'error') { } else if (edgeRunStatus === 'error') {
color = 'var(--text-error)' color = 'var(--text-error)'
} else if (isErrorEdge) {
// Error edges that weren't taken stay red
color = 'var(--text-error)'
} }
if (isSelected) { if (isSelected) {
@@ -151,4 +154,14 @@ const WorkflowEdgeComponent = ({
) )
} }
/**
* Workflow edge component with execution status and diff visualization.
*
* @remarks
* Edge coloring priority:
* 1. Diff status (deleted/new) - for version comparison
* 2. Execution status (success/error) - for run visualization
* 3. Error edge default (red) - for untaken error paths
* 4. Default edge color - normal workflow connections
*/
export const WorkflowEdge = memo(WorkflowEdgeComponent) export const WorkflowEdge = memo(WorkflowEdgeComponent)

View File

@@ -1641,51 +1641,36 @@ const WorkflowContent = React.memo(() => {
}, [screenToFlowPosition, handleToolbarDrop]) }, [screenToFlowPosition, handleToolbarDrop])
/** /**
* Focus canvas on changed blocks when diff appears * Focus canvas on changed blocks when diff appears.
* Focuses on new/edited blocks rather than fitting the entire workflow
*/ */
const pendingZoomBlockIdsRef = useRef<Set<string> | null>(null)
const prevDiffReadyRef = useRef(false) const prevDiffReadyRef = useRef(false)
// Phase 1: When diff becomes ready, record which blocks we want to zoom to
// Phase 2 effect is located after displayNodes is defined (search for "Phase 2")
useEffect(() => { useEffect(() => {
// Only focus when diff transitions from not ready to ready
if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) { if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) {
// Diff just became ready - record blocks to zoom to
const changedBlockIds = [ const changedBlockIds = [
...(diffAnalysis.new_blocks || []), ...(diffAnalysis.new_blocks || []),
...(diffAnalysis.edited_blocks || []), ...(diffAnalysis.edited_blocks || []),
] ]
if (changedBlockIds.length > 0) { if (changedBlockIds.length > 0) {
const allNodes = getNodes() pendingZoomBlockIdsRef.current = new Set(changedBlockIds)
const changedNodes = allNodes.filter((node) => changedBlockIds.includes(node.id))
if (changedNodes.length > 0) {
logger.info('Diff ready - focusing on changed blocks', {
changedBlockIds,
foundNodes: changedNodes.length,
})
requestAnimationFrame(() => {
fitViewToBounds({
nodes: changedNodes,
duration: 600,
padding: 0.1,
minZoom: 0.5,
maxZoom: 1.0,
})
})
} else { } else {
logger.info('Diff ready - no changed nodes found, fitting all') // No specific blocks to focus on, fit all after a frame
requestAnimationFrame(() => { pendingZoomBlockIdsRef.current = null
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
} else {
logger.info('Diff ready - no changed blocks, fitting all')
requestAnimationFrame(() => { requestAnimationFrame(() => {
fitViewToBounds({ padding: 0.1, duration: 600 }) fitViewToBounds({ padding: 0.1, duration: 600 })
}) })
} }
} else if (!isDiffReady && prevDiffReadyRef.current) {
// Diff was cleared (accepted/rejected) - cancel any pending zoom
pendingZoomBlockIdsRef.current = null
} }
prevDiffReadyRef.current = isDiffReady prevDiffReadyRef.current = isDiffReady
}, [isDiffReady, diffAnalysis, fitViewToBounds, getNodes]) }, [isDiffReady, diffAnalysis, fitViewToBounds])
/** Displays trigger warning notifications. */ /** Displays trigger warning notifications. */
useEffect(() => { useEffect(() => {
@@ -2093,6 +2078,48 @@ const WorkflowContent = React.memo(() => {
}) })
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection]) }, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
// Phase 2: When displayNodes updates, check if pending zoom blocks are ready
// (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined)
useEffect(() => {
const pendingBlockIds = pendingZoomBlockIdsRef.current
if (!pendingBlockIds || pendingBlockIds.size === 0) {
return
}
// Find the nodes we're waiting for
const pendingNodes = displayNodes.filter((node) => pendingBlockIds.has(node.id))
// Check if all expected nodes are present with valid dimensions
const allNodesReady =
pendingNodes.length === pendingBlockIds.size &&
pendingNodes.every(
(node) =>
typeof node.width === 'number' &&
typeof node.height === 'number' &&
node.width > 0 &&
node.height > 0
)
if (allNodesReady) {
logger.info('Diff ready - focusing on changed blocks', {
changedBlockIds: Array.from(pendingBlockIds),
foundNodes: pendingNodes.length,
})
// Clear pending state before zooming to prevent re-triggers
pendingZoomBlockIdsRef.current = null
// Use requestAnimationFrame to ensure React has finished rendering
requestAnimationFrame(() => {
fitViewToBounds({
nodes: pendingNodes,
duration: 600,
padding: 0.1,
minZoom: 0.5,
maxZoom: 1.0,
})
})
}
}, [displayNodes, fitViewToBounds])
/** Handles ActionBar remove-from-subflow events. */ /** Handles ActionBar remove-from-subflow events. */
useEffect(() => { useEffect(() => {
const handleRemoveFromSubflow = (event: Event) => { const handleRemoveFromSubflow = (event: Event) => {

View File

@@ -0,0 +1 @@
export { PreviewContextMenu } from './preview-context-menu'

View File

@@ -0,0 +1,97 @@
'use client'
import type { RefObject } from 'react'
import { createPortal } from 'react-dom'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface PreviewContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onCopy: () => void
onSearch?: () => void
wrapText?: boolean
onToggleWrap?: () => void
/** When true, only shows Copy option (for subblock values) */
copyOnly?: boolean
}
/**
* Context menu for preview editor sidebar.
* Provides copy, search, and display options.
* Uses createPortal to render outside any transformed containers (like modals).
*/
export function PreviewContextMenu({
isOpen,
position,
menuRef,
onClose,
onCopy,
onSearch,
wrapText,
onToggleWrap,
copyOnly = false,
}: PreviewContextMenuProps) {
if (typeof document === 'undefined') return null
return createPortal(
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onCopy()
onClose()
}}
>
Copy
</PopoverItem>
{!copyOnly && onSearch && (
<>
<PopoverDivider />
<PopoverItem
onClick={() => {
onSearch()
onClose()
}}
>
Search
</PopoverItem>
</>
)}
{!copyOnly && onToggleWrap && (
<>
<PopoverDivider />
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>,
document.body
)
}

View File

@@ -0,0 +1 @@
export { PreviewEditor } from './preview-editor'

View File

@@ -6,12 +6,24 @@ import {
ArrowUp, ArrowUp,
ChevronDown as ChevronDownIcon, ChevronDown as ChevronDownIcon,
ChevronUp, ChevronUp,
ExternalLink,
Maximize2,
RepeatIcon, RepeatIcon,
SplitIcon, SplitIcon,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { useParams } from 'next/navigation'
import { ReactFlowProvider } from 'reactflow' import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn' import {
Badge,
Button,
ChevronDown,
Code,
Combobox,
Input,
Label,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting' import { formatDuration } from '@/lib/core/utils/formatting'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references' import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
@@ -22,15 +34,42 @@ import {
isSubBlockFeatureEnabled, isSubBlockFeatureEnabled,
isSubBlockVisibleForMode, isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility' } from '@/lib/workflows/subblocks/visibility'
import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types' import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants' import { normalizeName } from '@/executor/constants'
import { navigatePath } from '@/executor/variables/resolvers/reference' import { navigatePath } from '@/executor/variables/resolvers/reference'
import { useWorkflowState } from '@/hooks/queries/workflows'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
/**
* CSS override to show full opacity and prevent interaction in readonly preview mode.
* Extracted to avoid duplicating the style block in multiple places.
*/
const READONLY_PREVIEW_STYLES = `
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`
/** /**
* Format a value for display as JSON string * Format a value for display as JSON string
@@ -123,44 +162,31 @@ function formatInlineValue(value: unknown): string {
return String(value) return String(value)
} }
interface ExecutionDataSectionProps { interface CollapsibleSectionProps {
title: string title: string
data: unknown defaultExpanded?: boolean
children: React.ReactNode
isEmpty?: boolean
emptyMessage?: string
/** Whether this section represents an error state (styles title red) */
isError?: boolean isError?: boolean
wrapText?: boolean
searchQuery?: string
currentMatchIndex?: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
onContextMenu?: (e: React.MouseEvent) => void
} }
/** /**
* Collapsible section for execution data (input/output) * Collapsible section wrapper for organizing preview editor content
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
*/ */
function ExecutionDataSection({ function CollapsibleSection({
title, title,
data, defaultExpanded = false,
children,
isEmpty = false,
emptyMessage = 'No data',
isError = false, isError = false,
wrapText = true, }: CollapsibleSectionProps) {
searchQuery, const [isExpanded, setIsExpanded] = useState(defaultExpanded)
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
onContextMenu,
}: ExecutionDataSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const jsonString = useMemo(() => {
if (!data) return ''
return formatValueAsJson(data)
}, [data])
const isEmpty = jsonString === '—' || jsonString === ''
return ( return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'> <div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div <div
className='group flex cursor-pointer items-center justify-between' className='group flex cursor-pointer items-center justify-between'
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
@@ -199,20 +225,10 @@ function ExecutionDataSection({
<> <>
{isEmpty ? ( {isEmpty ? (
<div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'> <div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
<span className='text-[12px] text-[var(--text-tertiary)]'>No data</span> <span className='text-[12px] text-[var(--text-tertiary)]'>{emptyMessage}</span>
</div> </div>
) : ( ) : (
<div onContextMenu={onContextMenu} ref={contentRef}> children
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={searchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={onMatchCountChange}
/>
</div>
)} )}
</> </>
)} )}
@@ -261,9 +277,12 @@ function ConnectionsSection({
const [expandedVariables, setExpandedVariables] = useState(true) const [expandedVariables, setExpandedVariables] = useState(true)
const [expandedEnvVars, setExpandedEnvVars] = useState(true) const [expandedEnvVars, setExpandedEnvVars] = useState(true)
/** Stable string of connection IDs to prevent effect from running on every render */
const connectionIds = useMemo(() => connections.map((c) => c.blockId).join(','), [connections])
useEffect(() => { useEffect(() => {
setExpandedBlocks(new Set(connections.map((c) => c.blockId))) setExpandedBlocks(new Set(connectionIds.split(',').filter(Boolean)))
}, [connections]) }, [connectionIds])
const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0 const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0
@@ -549,27 +568,22 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
const isLoop = block.type === 'loop' const isLoop = block.type === 'loop'
const config = isLoop ? SUBFLOW_CONFIG.loop : SUBFLOW_CONFIG.parallel const config = isLoop ? SUBFLOW_CONFIG.loop : SUBFLOW_CONFIG.parallel
// Determine current type
const currentType = isLoop const currentType = isLoop
? loop?.loopType || (block.data?.loopType as string) || 'for' ? loop?.loopType || (block.data?.loopType as string) || 'for'
: parallel?.parallelType || (block.data?.parallelType as string) || 'count' : parallel?.parallelType || (block.data?.parallelType as string) || 'count'
// Build type options for combobox - matches SubflowEditor
const typeOptions = Object.entries(config.typeLabels).map(([value, label]) => ({ const typeOptions = Object.entries(config.typeLabels).map(([value, label]) => ({
value, value,
label, label,
})) }))
// Determine mode
const isCountMode = currentType === 'for' || currentType === 'count' const isCountMode = currentType === 'for' || currentType === 'count'
const isConditionMode = currentType === 'while' || currentType === 'doWhile' const isConditionMode = currentType === 'while' || currentType === 'doWhile'
// Get iterations value
const iterations = isLoop const iterations = isLoop
? (loop?.iterations ?? (block.data?.count as number) ?? 5) ? (loop?.iterations ?? (block.data?.count as number) ?? 5)
: (parallel?.count ?? (block.data?.count as number) ?? 1) : (parallel?.count ?? (block.data?.count as number) ?? 1)
// Get collection/condition value
const getEditorValue = (): string => { const getEditorValue = (): string => {
if (isConditionMode && isLoop) { if (isConditionMode && isLoop) {
if (currentType === 'while') { if (currentType === 'while') {
@@ -589,7 +603,6 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
const editorValue = getEditorValue() const editorValue = getEditorValue()
// Get label for configuration field - matches SubflowEditor exactly
const getConfigLabel = (): string => { const getConfigLabel = (): string => {
if (isCountMode) { if (isCountMode) {
return `${isLoop ? 'Loop' : 'Parallel'} Iterations` return `${isLoop ? 'Loop' : 'Parallel'} Iterations`
@@ -601,7 +614,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
} }
return ( return (
<div className='flex-1 overflow-y-auto overflow-x-hidden pt-[5px] pb-[8px]'> <div className='flex-1 overflow-y-auto overflow-x-hidden pt-[8px] pb-[8px]'>
{/* Type Selection - matches SubflowEditor */} {/* Type Selection - matches SubflowEditor */}
<div> <div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'> <Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
@@ -703,6 +716,8 @@ interface PreviewEditorProps {
isExecutionMode?: boolean isExecutionMode?: boolean
/** Optional close handler - if not provided, no close button is shown */ /** Optional close handler - if not provided, no close button is shown */
onClose?: () => void onClose?: () => void
/** Callback to drill down into a nested workflow block */
onDrillDown?: (blockId: string, childWorkflowState: WorkflowState) => void
} }
/** Minimum height for the connections section (header only) */ /** Minimum height for the connections section (header only) */
@@ -725,8 +740,8 @@ function PreviewEditorContent({
parallels, parallels,
isExecutionMode = false, isExecutionMode = false,
onClose, onClose,
onDrillDown,
}: PreviewEditorProps) { }: PreviewEditorProps) {
// Convert Record<string, Variable> to Array<Variable> for iteration
const normalizedWorkflowVariables = useMemo(() => { const normalizedWorkflowVariables = useMemo(() => {
if (!workflowVariables) return [] if (!workflowVariables) return []
return Object.values(workflowVariables) return Object.values(workflowVariables)
@@ -735,10 +750,39 @@ function PreviewEditorContent({
const blockConfig = getBlock(block.type) as BlockConfig | undefined const blockConfig = getBlock(block.type) as BlockConfig | undefined
const subBlockValues = block.subBlocks || {} const subBlockValues = block.subBlocks || {}
const params = useParams()
const workspaceId = params.workspaceId as string
const isWorkflowBlock = block.type === 'workflow' || block.type === 'workflow_input'
/** Extracts child workflow ID from subblock values for workflow blocks */
const childWorkflowId = useMemo(() => {
if (!isWorkflowBlock) return null
const workflowIdValue = subBlockValues?.workflowId
if (workflowIdValue && typeof workflowIdValue === 'object' && 'value' in workflowIdValue) {
return (workflowIdValue as { value: unknown }).value as string | null
}
return workflowIdValue as string | null
}, [isWorkflowBlock, subBlockValues?.workflowId])
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
childWorkflowId ?? undefined
)
/** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => {
if (!childWorkflowId || !childWorkflowState) return
if (isExecutionMode && onDrillDown) {
onDrillDown(block.id, childWorkflowState)
} else if (workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
}
}, [childWorkflowId, childWorkflowState, isExecutionMode, onDrillDown, block.id, workspaceId])
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
const subBlocksRef = useRef<HTMLDivElement>(null) const subBlocksRef = useRef<HTMLDivElement>(null)
// Connections resize state
const [connectionsHeight, setConnectionsHeight] = useState(DEFAULT_CONNECTIONS_HEIGHT) const [connectionsHeight, setConnectionsHeight] = useState(DEFAULT_CONNECTIONS_HEIGHT)
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const startYRef = useRef<number>(0) const startYRef = useRef<number>(0)
@@ -846,10 +890,8 @@ function PreviewEditorContent({
if (!isResizing) return if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const deltaY = startYRef.current - e.clientY // Inverted because we're resizing from bottom up const deltaY = startYRef.current - e.clientY
let newHeight = startHeightRef.current + deltaY let newHeight = startHeightRef.current + deltaY
// Clamp height between fixed min and max for stable behavior
newHeight = Math.max(MIN_CONNECTIONS_HEIGHT, Math.min(MAX_CONNECTIONS_HEIGHT, newHeight)) newHeight = Math.max(MIN_CONNECTIONS_HEIGHT, Math.min(MAX_CONNECTIONS_HEIGHT, newHeight))
setConnectionsHeight(newHeight) setConnectionsHeight(newHeight)
} }
@@ -871,7 +913,6 @@ function PreviewEditorContent({
} }
}, [isResizing]) }, [isResizing])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5 const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5
const blockNameToId = useMemo(() => { const blockNameToId = useMemo(() => {
@@ -891,7 +932,7 @@ function PreviewEditorContent({
if (!allBlockExecutions || !workflowBlocks) return undefined if (!allBlockExecutions || !workflowBlocks) return undefined
if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
const inner = reference.slice(1, -1) // Remove < and > const inner = reference.slice(1, -1)
const parts = inner.split('.') const parts = inner.split('.')
if (parts.length < 1) return undefined if (parts.length < 1) return undefined
@@ -1007,12 +1048,10 @@ function PreviewEditorContent({
[blockConfig?.subBlocks] [blockConfig?.subBlocks]
) )
// Check if this is a subflow block (loop or parallel)
const isSubflow = block.type === 'loop' || block.type === 'parallel' const isSubflow = block.type === 'loop' || block.type === 'parallel'
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
const parallelConfig = block.type === 'parallel' ? parallels?.[block.id] : undefined const parallelConfig = block.type === 'parallel' ? parallels?.[block.id] : undefined
// Handle subflow blocks
if (isSubflow) { if (isSubflow) {
const isLoop = block.type === 'loop' const isLoop = block.type === 'loop'
const SubflowIcon = isLoop ? RepeatIcon : SplitIcon const SubflowIcon = isLoop ? RepeatIcon : SplitIcon
@@ -1043,27 +1082,7 @@ function PreviewEditorContent({
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'> <div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'> <div className='flex-1 overflow-y-auto overflow-x-hidden'>
<div className='readonly-preview px-[8px]'> <div className='readonly-preview px-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */} <style>{READONLY_PREVIEW_STYLES}</style>
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
<SubflowConfigDisplay block={block} loop={loopConfig} parallel={parallelConfig} /> <SubflowConfigDisplay block={block} loop={loopConfig} parallel={parallelConfig} />
</div> </div>
</div> </div>
@@ -1095,8 +1114,6 @@ function PreviewEditorContent({
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => { const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false if (subBlock.hidden || subBlock.hideFromPreview) return false
// Only filter out trigger-mode subblocks for non-trigger blocks
// Trigger-only blocks (category 'triggers') should display their trigger subblocks
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (!isSubBlockFeatureEnabled(subBlock)) return false if (!isSubBlockFeatureEnabled(subBlock)) return false
if ( if (
@@ -1145,7 +1162,7 @@ function PreviewEditorContent({
{/* Content area */} {/* Content area */}
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'> <div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Subblocks Section */} {/* Main content sections */}
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'> <div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'> <div className='flex-1 overflow-y-auto overflow-x-hidden'>
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */} {/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
@@ -1159,13 +1176,9 @@ function PreviewEditorContent({
</div> </div>
)} )}
{/* Execution Input/Output (if provided) */}
{executionData &&
(executionData.input !== undefined || executionData.output !== undefined) ? (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{/* Execution Status & Duration Header */} {/* Execution Status & Duration Header */}
{(executionData.status || executionData.durationMs !== undefined) && ( {executionData && (executionData.status || executionData.durationMs !== undefined) && (
<div className='flex items-center justify-between'> <div className='flex min-w-0 items-center justify-between overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{executionData.status && ( {executionData.status && (
<Badge variant={statusVariant} size='sm' dot> <Badge variant={statusVariant} size='sm' dot>
<span className='capitalize'>{executionData.status}</span> <span className='capitalize'>{executionData.status}</span>
@@ -1179,71 +1192,138 @@ function PreviewEditorContent({
</div> </div>
)} )}
{/* Divider between Status/Duration and Input/Output */} {/* Input Section - Collapsible */}
{(executionData.status || executionData.durationMs !== undefined) && {executionData?.input !== undefined && (
(executionData.input !== undefined || executionData.output !== undefined) && ( <CollapsibleSection
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Input Section */}
{executionData.input !== undefined && (
<ExecutionDataSection
title='Input' title='Input'
data={executionData.input} defaultExpanded={false}
isEmpty={
formatValueAsJson(executionData.input) === '—' ||
formatValueAsJson(executionData.input) === ''
}
emptyMessage='No input data'
>
<div onContextMenu={handleExecutionContextMenu} ref={contentRef}>
<Code.Viewer
code={formatValueAsJson(executionData.input)}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText} wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined} searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex} currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange} onMatchCountChange={handleMatchCountChange}
contentRef={contentRef}
onContextMenu={handleExecutionContextMenu}
/> />
</div>
</CollapsibleSection>
)} )}
{/* Divider between Input and Output */} {/* Output Section - Collapsible, expanded by default */}
{executionData.input !== undefined && executionData.output !== undefined && ( {executionData?.output !== undefined && (
<div className='border-[var(--border)] border-t border-dashed' /> <CollapsibleSection
)}
{/* Output Section */}
{executionData.output !== undefined && (
<ExecutionDataSection
title={executionData.status === 'error' ? 'Error' : 'Output'} title={executionData.status === 'error' ? 'Error' : 'Output'}
data={executionData.output} defaultExpanded={true}
isEmpty={
formatValueAsJson(executionData.output) === '—' ||
formatValueAsJson(executionData.output) === ''
}
emptyMessage='No output data'
isError={executionData.status === 'error'} isError={executionData.status === 'error'}
>
<div onContextMenu={handleExecutionContextMenu}>
<Code.Viewer
code={formatValueAsJson(executionData.output)}
language='json'
className={cn(
'!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]',
executionData.status === 'error' && 'text-[var(--text-error)]'
)}
wrapText={wrapText} wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined} searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex} currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange} onMatchCountChange={handleMatchCountChange}
contentRef={contentRef}
onContextMenu={handleExecutionContextMenu}
/> />
</div>
</CollapsibleSection>
)}
{/* Workflow Preview - only for workflow blocks with a selected child workflow */}
{isWorkflowBlock && childWorkflowId && (
<div className='px-[8px] pt-[12px]'>
<div className='subblock-content flex flex-col gap-[9.5px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)] leading-none'>
Workflow Preview
</div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{isLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
) : childWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<PreviewWorkflow
workflowState={childWorkflowState}
height={160}
width='100%'
isPannable={true}
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleExpandChildWorkflow}
className='absolute right-[6px] bottom-[6px] z-10 h-[24px] w-[24px] cursor-pointer border border-[var(--border)] bg-[var(--surface-2)] p-0 hover:bg-[var(--surface-4)]'
>
{isExecutionMode && onDrillDown ? (
<Maximize2 className='h-[12px] w-[12px]' />
) : (
<ExternalLink className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isExecutionMode && onDrillDown ? 'Expand workflow' : 'Open in new tab'}
</Tooltip.Content>
</Tooltip.Root>
</>
) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
Unable to load preview
</span>
</div>
)} )}
</div> </div>
) : null} </div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</div>
)}
{/* Subblock Values - Using SubBlock components in preview mode */} {/* Subblock Values - Using SubBlock components in preview mode */}
<div className='readonly-preview px-[8px] py-[8px]'> <div className='readonly-preview px-[8px] pt-[12px] pb-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */} <style>{READONLY_PREVIEW_STYLES}</style>
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
{visibleSubBlocks.length > 0 ? ( {visibleSubBlocks.length > 0 ? (
<div className='flex flex-col'> <div className='flex flex-col'>
{visibleSubBlocks.map((subBlockConfig, index) => ( {visibleSubBlocks.map((subBlockConfig, index) => (
@@ -1349,7 +1429,7 @@ function PreviewEditorContent({
)} )}
{/* Context Menu */} {/* Context Menu */}
<SnapshotContextMenu <PreviewContextMenu
isOpen={isContextMenuOpen} isOpen={isContextMenuOpen}
position={contextMenuPosition} position={contextMenuPosition}
menuRef={contextMenuRef} menuRef={contextMenuRef}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useMemo } from 'react' import { type CSSProperties, memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow' import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { import {
@@ -23,6 +23,27 @@ interface SubBlockValueEntry {
value: unknown value: unknown
} }
/**
* Handle style constants for preview blocks.
* Extracted to avoid recreating style objects on each render.
*/
const HANDLE_STYLES = {
horizontal: '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]',
vertical: '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]',
right:
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
error:
'!z-[10] !border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
} as const
/** Reusable style object for error handles positioned at bottom-right */
const ERROR_HANDLE_STYLE: CSSProperties = {
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}
interface WorkflowPreviewBlockData { interface WorkflowPreviewBlockData {
type: string type: string
name: string name: string
@@ -35,6 +56,8 @@ interface WorkflowPreviewBlockData {
executionStatus?: ExecutionStatus executionStatus?: ExecutionStatus
/** Subblock values from the workflow state */ /** Subblock values from the workflow state */
subBlockValues?: Record<string, SubBlockValueEntry | unknown> subBlockValues?: Record<string, SubBlockValueEntry | unknown>
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
} }
/** /**
@@ -157,21 +180,17 @@ function resolveToolsDisplay(
if (!tool || typeof tool !== 'object') return null if (!tool || typeof tool !== 'object') return null
const t = tool as Record<string, unknown> const t = tool as Record<string, unknown>
// Priority 1: Use tool.title if already populated
if (t.title && typeof t.title === 'string') return t.title if (t.title && typeof t.title === 'string') return t.title
// Priority 2: Extract from inline schema (legacy format)
const schema = t.schema as Record<string, unknown> | undefined const schema = t.schema as Record<string, unknown> | undefined
if (schema?.function && typeof schema.function === 'object') { if (schema?.function && typeof schema.function === 'object') {
const fn = schema.function as Record<string, unknown> const fn = schema.function as Record<string, unknown>
if (fn.name && typeof fn.name === 'string') return fn.name if (fn.name && typeof fn.name === 'string') return fn.name
} }
// Priority 3: Extract from OpenAI function format
const fn = t.function as Record<string, unknown> | undefined const fn = t.function as Record<string, unknown> | undefined
if (fn?.name && typeof fn.name === 'string') return fn.name if (fn?.name && typeof fn.name === 'string') return fn.name
// Priority 4: Resolve built-in tool blocks from registry
if ( if (
typeof t.type === 'string' && typeof t.type === 'string' &&
t.type !== 'custom-tool' && t.type !== 'custom-tool' &&
@@ -204,21 +223,16 @@ function resolveToolsDisplay(
* - Shows '-' for other selector types that need hydration * - Shows '-' for other selector types that need hydration
*/ */
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) { function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
// Mask password fields
const isPasswordField = subBlock?.password === true const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
// Resolve various display names (synchronous access, matching WorkflowBlock priority)
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue) const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue) const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
const toolsDisplay = resolveToolsDisplay(subBlock, rawValue) const toolsDisplay = resolveToolsDisplay(subBlock, rawValue)
const workflowName = resolveWorkflowName(subBlock, rawValue) const workflowName = resolveWorkflowName(subBlock, rawValue)
// Check if this is a selector type that needs hydration (show '-' for raw IDs)
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type) const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
// Compute final display value matching WorkflowBlock logic
// Priority order matches WorkflowBlock: masked > hydrated names > selector fallback > raw value
const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
@@ -258,6 +272,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
isPreviewSelected = false, isPreviewSelected = false,
executionStatus, executionStatus,
subBlockValues, subBlockValues,
lightweight = false,
} = data } = data
const blockConfig = getBlock(type) const blockConfig = getBlock(type)
@@ -268,44 +283,68 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
) )
const rawValues = useMemo(() => { const rawValues = useMemo(() => {
if (!subBlockValues) return {} if (lightweight || !subBlockValues) return {}
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => { return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
acc[key] = extractValue(entry) acc[key] = extractValue(entry)
return acc return acc
}, {}) }, {})
}, [subBlockValues]) }, [subBlockValues, lightweight])
const visibleSubBlocks = useMemo(() => { const visibleSubBlocks = useMemo(() => {
if (!blockConfig?.subBlocks) return [] if (!blockConfig?.subBlocks) return []
const isStarterOrTrigger = const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
blockConfig.category === 'triggers' || type === 'starter' || isTrigger const effectiveTrigger = isTrigger || type === 'starter'
return blockConfig.subBlocks.filter((subBlock) => { return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden) return false if (subBlock.hidden) return false
if (subBlock.hideFromPreview) return false if (subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) return false if (!isSubBlockFeatureEnabled(subBlock)) return false
// Handle trigger mode visibility if (effectiveTrigger) {
if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false const isValidTriggerSubblock = isPureTriggerBlock
? subBlock.mode === 'trigger' || !subBlock.mode
: subBlock.mode === 'trigger'
if (!isValidTriggerSubblock) return false
} else {
if (subBlock.mode === 'trigger') return false
}
/** Skip value-dependent visibility checks in lightweight mode */
if (lightweight) return !subBlock.condition
// Check advanced mode visibility
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) { if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
return false return false
} }
// Check condition visibility
if (!subBlock.condition) return true if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues) return evaluateSubBlockCondition(subBlock.condition, rawValues)
}) })
}, [blockConfig?.subBlocks, blockConfig?.category, type, isTrigger, canonicalIndex, rawValues]) }, [
lightweight,
blockConfig?.subBlocks,
blockConfig?.triggers?.enabled,
blockConfig?.category,
type,
isTrigger,
canonicalIndex,
rawValues,
])
/** /**
* Compute condition rows for condition blocks * Compute condition rows for condition blocks.
* In lightweight mode, returns default structure without parsing values.
*/ */
const conditionRows = useMemo(() => { const conditionRows = useMemo(() => {
if (type !== 'condition') return [] if (type !== 'condition') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
if (lightweight) return defaultRows
const conditionsValue = rawValues.conditions const conditionsValue = rawValues.conditions
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
@@ -325,21 +364,24 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
} }
} }
} catch { } catch {
// Failed to parse, use fallback /* empty */
} }
return [ return defaultRows
{ id: 'if', title: 'if', value: '' }, }, [type, rawValues, lightweight])
{ id: 'else', title: 'else', value: '' },
]
}, [type, rawValues])
/** /**
* Compute router rows for router_v2 blocks * Compute router rows for router_v2 blocks.
* In lightweight mode, returns default structure without parsing values.
*/ */
const routerRows = useMemo(() => { const routerRows = useMemo(() => {
if (type !== 'router_v2') return [] if (type !== 'router_v2') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [{ id: 'route1', value: '' }]
if (lightweight) return defaultRows
const routesValue = rawValues.routes const routesValue = rawValues.routes
const raw = typeof routesValue === 'string' ? routesValue : undefined const raw = typeof routesValue === 'string' ? routesValue : undefined
@@ -357,11 +399,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
} }
} }
} catch { } catch {
// Failed to parse, use fallback /* empty */
} }
return [{ id: 'route1', value: '' }] return defaultRows
}, [type, rawValues]) }, [type, rawValues, lightweight])
if (!blockConfig) { if (!blockConfig) {
return null return null
@@ -379,9 +421,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
? routerRows.length > 0 || shouldShowDefaultHandles ? routerRows.length > 0 || shouldShowDefaultHandles
: hasSubBlocks || shouldShowDefaultHandles : hasSubBlocks || shouldShowDefaultHandles
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
const hasError = executionStatus === 'error' const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success' const hasSuccess = executionStatus === 'success'
@@ -406,7 +445,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='target' type='target'
position={horizontalHandles ? Position.Left : Position.Top} position={horizontalHandles ? Position.Left : Position.Top}
id='target' id='target'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass} className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
style={ style={
horizontalHandles horizontalHandles
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` } ? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
@@ -439,36 +478,37 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
{hasContentBelowHeader && ( {hasContentBelowHeader && (
<div className='flex flex-col gap-[8px] p-[8px]'> <div className='flex flex-col gap-[8px] p-[8px]'>
{type === 'condition' ? ( {type === 'condition' ? (
// Condition block: render condition rows
conditionRows.map((cond) => ( conditionRows.map((cond) => (
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} /> <SubBlockRow
key={cond.id}
title={cond.title}
value={lightweight ? undefined : getDisplayValue(cond.value)}
/>
)) ))
) : type === 'router_v2' ? ( ) : type === 'router_v2' ? (
// Router block: render context + route rows
<> <>
<SubBlockRow <SubBlockRow
key='context' key='context'
title='Context' title='Context'
value={getDisplayValue(rawValues.context)} value={lightweight ? undefined : getDisplayValue(rawValues.context)}
/> />
{routerRows.map((route, index) => ( {routerRows.map((route, index) => (
<SubBlockRow <SubBlockRow
key={route.id} key={route.id}
title={`Route ${index + 1}`} title={`Route ${index + 1}`}
value={getDisplayValue(route.value)} value={lightweight ? undefined : getDisplayValue(route.value)}
/> />
))} ))}
</> </>
) : ( ) : (
// Standard blocks: render visible subblocks
visibleSubBlocks.map((subBlock) => { visibleSubBlocks.map((subBlock) => {
const rawValue = rawValues[subBlock.id] const rawValue = lightweight ? undefined : rawValues[subBlock.id]
return ( return (
<SubBlockRow <SubBlockRow
key={subBlock.id} key={subBlock.id}
title={subBlock.title ?? subBlock.id} title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)} value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={subBlock} subBlock={lightweight ? undefined : subBlock}
rawValue={rawValue} rawValue={rawValue}
/> />
) )
@@ -479,27 +519,101 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
</div> </div>
)} )}
{/* Source handle */} {/* Condition block handles */}
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y + condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${cond.id}`}
type='source'
position={Position.Right}
id={`condition-${cond.id}`}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Router block handles */}
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Source and error handles for non-condition/router blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
<Handle <Handle
type='source' type='source'
position={horizontalHandles ? Position.Right : Position.Bottom} position={horizontalHandles ? Position.Right : Position.Bottom}
id='source' id='source'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass} className={horizontalHandles ? HANDLE_STYLES.right : HANDLE_STYLES.vertical}
style={ style={
horizontalHandles horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` } ? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' } : { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
} }
/> />
{shouldShowDefaultHandles && (
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
)}
</>
)}
</div> </div>
) )
} }
/**
* Custom comparison function for React.memo optimization.
* Uses fast-path primitive comparison before shallow comparing subBlockValues.
* @param prevProps - Previous render props
* @param nextProps - Next render props
* @returns True if render should be skipped (props are equal)
*/
function shouldSkipPreviewBlockRender( function shouldSkipPreviewBlockRender(
prevProps: NodeProps<WorkflowPreviewBlockData>, prevProps: NodeProps<WorkflowPreviewBlockData>,
nextProps: NodeProps<WorkflowPreviewBlockData> nextProps: NodeProps<WorkflowPreviewBlockData>
): boolean { ): boolean {
// Check primitive props first (fast path)
if ( if (
prevProps.id !== nextProps.id || prevProps.id !== nextProps.id ||
prevProps.data.type !== nextProps.data.type || prevProps.data.type !== nextProps.data.type ||
@@ -508,38 +622,41 @@ function shouldSkipPreviewBlockRender(
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles || prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
prevProps.data.enabled !== nextProps.data.enabled || prevProps.data.enabled !== nextProps.data.enabled ||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected || prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
prevProps.data.executionStatus !== nextProps.data.executionStatus prevProps.data.executionStatus !== nextProps.data.executionStatus ||
prevProps.data.lightweight !== nextProps.data.lightweight
) { ) {
return false return false
} }
// Compare subBlockValues by reference first /** Skip subBlockValues comparison in lightweight mode */
if (nextProps.data.lightweight) return true
const prevValues = prevProps.data.subBlockValues const prevValues = prevProps.data.subBlockValues
const nextValues = nextProps.data.subBlockValues const nextValues = nextProps.data.subBlockValues
if (prevValues === nextValues) { if (prevValues === nextValues) return true
return true if (!prevValues || !nextValues) return false
}
if (!prevValues || !nextValues) {
return false
}
// Shallow compare keys and values
const prevKeys = Object.keys(prevValues) const prevKeys = Object.keys(prevValues)
const nextKeys = Object.keys(nextValues) const nextKeys = Object.keys(nextValues)
if (prevKeys.length !== nextKeys.length) { if (prevKeys.length !== nextKeys.length) return false
return false
}
for (const key of prevKeys) { for (const key of prevKeys) {
if (prevValues[key] !== nextValues[key]) { if (prevValues[key] !== nextValues[key]) return false
return false
}
} }
return true return true
} }
export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender) /**
* Preview block component for workflow visualization in readonly contexts.
* Optimized for rendering without hooks or store subscriptions.
*
* @remarks
* - Renders block header, subblock values, and connection handles
* - Supports condition, router, and standard block types
* - Shows error handles for non-trigger blocks
* - Displays execution status via colored ring overlays
*/
export const PreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)

View File

@@ -0,0 +1 @@
export { PreviewBlock } from './block'

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