mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-27 07:48:22 -05:00
v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements
This commit is contained in:
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
name: Test and Build
|
||||
@@ -278,3 +281,30 @@ jobs:
|
||||
if: needs.check-docs-changes.outputs.docs_changed == 'true'
|
||||
uses: ./.github/workflows/docs-embeddings.yml
|
||||
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 }}
|
||||
|
||||
3
.github/workflows/docs-embeddings.yml
vendored
3
.github/workflows/docs-embeddings.yml
vendored
@@ -4,6 +4,9 @@ on:
|
||||
workflow_call:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
process-docs-embeddings:
|
||||
name: Process Documentation Embeddings
|
||||
|
||||
3
.github/workflows/migrations.yml
vendored
3
.github/workflows/migrations.yml
vendored
@@ -4,6 +4,9 @@ on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
migrate:
|
||||
name: Apply Database Migrations
|
||||
|
||||
3
.github/workflows/publish-cli.yml
vendored
3
.github/workflows/publish-cli.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
paths:
|
||||
- 'packages/cli/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
3
.github/workflows/publish-python-sdk.yml
vendored
3
.github/workflows/publish-python-sdk.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
paths:
|
||||
- 'packages/python-sdk/**'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish-pypi:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
3
.github/workflows/publish-ts-sdk.yml
vendored
3
.github/workflows/publish-ts-sdk.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
paths:
|
||||
- 'packages/ts-sdk/**'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
3
.github/workflows/test-build.yml
vendored
3
.github/workflows/test-build.yml
vendored
@@ -4,6 +4,9 @@ on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
name: Test and Build
|
||||
|
||||
@@ -185,11 +185,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
tableOfContent={{
|
||||
style: 'clerk',
|
||||
enabled: true,
|
||||
header: (
|
||||
<div key='toc-header' className='mb-2 font-medium text-sm'>
|
||||
On this page
|
||||
</div>
|
||||
),
|
||||
footer: <TOCFooter />,
|
||||
single: false,
|
||||
}}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { defineI18nUI } from 'fumadocs-ui/i18n'
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next'
|
||||
import { Geist_Mono, Inter } from 'next/font/google'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
SidebarFolder,
|
||||
SidebarItem,
|
||||
SidebarSeparator,
|
||||
} from '@/components/docs-layout/sidebar-components'
|
||||
import { Navbar } from '@/components/navbar/navbar'
|
||||
import { SimLogoFull } from '@/components/ui/sim-logo'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import { source } from '@/lib/source'
|
||||
import '../global.css'
|
||||
@@ -102,16 +102,7 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
<DocsLayout
|
||||
tree={source.pageTree[lang]}
|
||||
nav={{
|
||||
title: (
|
||||
<Image
|
||||
src='/static/logo.png'
|
||||
alt='Sim'
|
||||
width={72}
|
||||
height={28}
|
||||
className='h-7 w-auto'
|
||||
priority
|
||||
/>
|
||||
),
|
||||
title: <SimLogoFull className='h-7 w-auto' />,
|
||||
}}
|
||||
sidebar={{
|
||||
defaultOpenLevel: 0,
|
||||
|
||||
@@ -33,15 +33,41 @@ async function loadGoogleFont(font: string, weights: string, text: string): Prom
|
||||
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.
|
||||
* Style matches Cursor docs: dark background, title at top, logo bottom-left, domain bottom-right.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const title = searchParams.get('title') || 'Documentation'
|
||||
|
||||
const baseUrl = new URL(request.url).origin
|
||||
|
||||
const allText = `${title}docs.sim.ai`
|
||||
const fontData = await loadGoogleFont('Geist', '400;500;600', allText)
|
||||
|
||||
@@ -52,84 +78,39 @@ export async function GET(request: NextRequest) {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0c0c0c',
|
||||
position: 'relative',
|
||||
justifyContent: 'space-between',
|
||||
padding: '56px 64px',
|
||||
background: '#121212', // Dark mode background matching docs (hsla 0, 0%, 7%)
|
||||
fontFamily: 'Geist',
|
||||
}}
|
||||
>
|
||||
{/* Base gradient layer - subtle purple tint across the entire image */}
|
||||
<div
|
||||
{/* Title at top */}
|
||||
<span
|
||||
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',
|
||||
fontSize: getTitleFontSize(title),
|
||||
fontWeight: 500,
|
||||
color: '#fafafa', // Light text matching docs
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{/* Title */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: getTitleFontSize(title),
|
||||
fontWeight: 600,
|
||||
color: '#ffffff',
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer: icon left, domain right */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<SimLogoFull />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
fontWeight: 400,
|
||||
color: '#71717a',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -9,11 +9,20 @@ body {
|
||||
}
|
||||
|
||||
@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-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-sans {
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
@@ -34,7 +43,7 @@ body {
|
||||
:root {
|
||||
--fd-border: 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 */
|
||||
--spacing-fd-container: 1400px;
|
||||
/* 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 */
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--fd-banner-height: 64px !important;
|
||||
--fd-banner-height: 65px !important; /* 64px navbar + 1px border */
|
||||
}
|
||||
|
||||
#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;
|
||||
margin-left: var(--sidebar-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;
|
||||
}
|
||||
|
||||
/* Override active state (NO PURPLE) */
|
||||
/* Override active state */
|
||||
#nd-sidebar a[data-active="true"],
|
||||
#nd-sidebar button[data-active="true"],
|
||||
#nd-sidebar a.bg-fd-primary\/10,
|
||||
#nd-sidebar a.text-fd-primary,
|
||||
#nd-sidebar a[class*="bg-fd-primary"],
|
||||
#nd-sidebar a[class*="text-fd-primary"],
|
||||
/* Override custom sidebar purple classes */
|
||||
/* Override custom sidebar green classes */
|
||||
#nd-sidebar
|
||||
a.bg-purple-50\/80,
|
||||
#nd-sidebar a.text-purple-600,
|
||||
#nd-sidebar a[class*="bg-purple"],
|
||||
#nd-sidebar a[class*="text-purple"] {
|
||||
a.bg-emerald-50\/80,
|
||||
#nd-sidebar a.text-emerald-600,
|
||||
#nd-sidebar a[class*="bg-emerald"],
|
||||
#nd-sidebar a[class*="text-emerald"] {
|
||||
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[class*="bg-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.text-purple-600,
|
||||
html.dark #nd-sidebar a[class*="bg-purple"],
|
||||
html.dark #nd-sidebar a[class*="text-purple"] {
|
||||
html.dark #nd-sidebar a.bg-emerald-50\/80,
|
||||
html.dark #nd-sidebar a.text-emerald-600,
|
||||
html.dark #nd-sidebar a[class*="bg-emerald"],
|
||||
html.dark #nd-sidebar a[class*="text-emerald"] {
|
||||
background-color: rgba(255, 255, 255, 0.15) !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[class*="bg-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.text-purple-600,
|
||||
html:not(.dark) #nd-sidebar a[class*="bg-purple"],
|
||||
html:not(.dark) #nd-sidebar a[class*="text-purple"] {
|
||||
html:not(.dark) #nd-sidebar a.bg-emerald-50\/80,
|
||||
html:not(.dark) #nd-sidebar a.text-emerald-600,
|
||||
html:not(.dark) #nd-sidebar a[class*="bg-emerald"],
|
||||
html:not(.dark) #nd-sidebar a[class*="text-emerald"] {
|
||||
background-color: rgba(0, 0, 0, 0.07) !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 */
|
||||
html.dark #nd-sidebar a.bg-purple-50\/80:hover,
|
||||
html.dark #nd-sidebar a[class*="bg-purple"]:hover,
|
||||
html.dark #nd-sidebar a.bg-emerald-50\/80:hover,
|
||||
html.dark #nd-sidebar a[class*="bg-emerald"]:hover,
|
||||
html.dark #nd-sidebar a[data-active="true"]:hover,
|
||||
html.dark #nd-sidebar button[data-active="true"]:hover {
|
||||
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 */
|
||||
html:not(.dark) #nd-sidebar a.bg-purple-50\/80:hover,
|
||||
html:not(.dark) #nd-sidebar a[class*="bg-purple"]:hover,
|
||||
html:not(.dark) #nd-sidebar a.bg-emerald-50\/80: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 button[data-active="true"]:hover {
|
||||
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="Collapse Sidebar"],
|
||||
/* 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="/"] img,
|
||||
aside[data-sidebar] > a:first-child,
|
||||
aside[data-sidebar] > div > a:first-child,
|
||||
aside[data-sidebar] img[alt="Sim"],
|
||||
[data-sidebar-header],
|
||||
[data-sidebar] [data-title],
|
||||
aside[data-sidebar] svg[aria-label="Sim"],
|
||||
/* 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 > div:first-child > a:first-child,
|
||||
#nd-sidebar img[alt="Sim"],
|
||||
#nd-sidebar svg[aria-label="Sim"],
|
||||
/* Hide theme toggle at bottom of sidebar on desktop */
|
||||
#nd-sidebar
|
||||
> footer,
|
||||
@@ -515,6 +532,15 @@ pre code .line {
|
||||
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 */
|
||||
[data-content] {
|
||||
padding-top: 1.5rem !important;
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SidebarItem({ item }: { item: Item }) {
|
||||
'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: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}
|
||||
@@ -79,7 +79,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
'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: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}
|
||||
@@ -104,7 +104,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
'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: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}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function TOCFooter() {
|
||||
rel='noopener noreferrer'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
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'
|
||||
>
|
||||
<span>Get started</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { LanguageDropdown } from '@/components/ui/language-dropdown'
|
||||
import { SearchTrigger } from '@/components/ui/search-trigger'
|
||||
import { SimLogoFull } from '@/components/ui/sim-logo'
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle'
|
||||
|
||||
export function Navbar() {
|
||||
@@ -27,13 +27,7 @@ export function Navbar() {
|
||||
{/* Left cluster: logo */}
|
||||
<div className='flex items-center'>
|
||||
<Link href='/' className='flex min-w-[100px] items-center'>
|
||||
<Image
|
||||
src='/static/logo.png'
|
||||
alt='Sim'
|
||||
width={72}
|
||||
height={28}
|
||||
className='h-7 w-auto'
|
||||
/>
|
||||
<SimLogoFull className='h-7 w-auto' />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,40 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
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 }: ActionImageProps) {
|
||||
const resolvedSrc = getAssetUrl(src.startsWith('/') ? src.slice(1) : src)
|
||||
export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
if (enableLightbox) {
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={alt}
|
||||
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
|
||||
/>
|
||||
<>
|
||||
<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 }: ActionVideoProps) {
|
||||
const resolvedSrc = getAssetUrl(src.startsWith('/') ? src.slice(1) : src)
|
||||
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
|
||||
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
|
||||
/>
|
||||
<>
|
||||
<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'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,6 @@ import {
|
||||
ShopifyIcon,
|
||||
SlackIcon,
|
||||
SmtpIcon,
|
||||
SpotifyIcon,
|
||||
SQSIcon,
|
||||
SshIcon,
|
||||
STTIcon,
|
||||
@@ -182,7 +181,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
jina: JinaAIIcon,
|
||||
jira: JiraIcon,
|
||||
jira_service_management: JiraServiceManagementIcon,
|
||||
kalshi: KalshiIcon,
|
||||
kalshi_v2: KalshiIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
langsmith: LangsmithIcon,
|
||||
lemlist: LemlistIcon,
|
||||
@@ -229,7 +228,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
shopify: ShopifyIcon,
|
||||
slack: SlackIcon,
|
||||
smtp: SmtpIcon,
|
||||
spotify: SpotifyIcon,
|
||||
sqs: SQSIcon,
|
||||
ssh: SshIcon,
|
||||
stagehand: StagehandIcon,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
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 { cn } from '@/lib/utils'
|
||||
|
||||
const languages = {
|
||||
en: { name: 'English', flag: '🇺🇸' },
|
||||
@@ -15,6 +16,7 @@ const languages = {
|
||||
|
||||
export function LanguageDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number>(-1)
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
@@ -71,6 +73,15 @@ export function LanguageDropdown() {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isOpen])
|
||||
|
||||
// Reset hovered index when popover closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setHoveredIndex(-1)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const languageEntries = Object.entries(languages)
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<button
|
||||
@@ -82,14 +93,14 @@ export function LanguageDropdown() {
|
||||
aria-haspopup='listbox'
|
||||
aria-expanded={isOpen}
|
||||
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={{
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{isOpen && (
|
||||
@@ -98,29 +109,37 @@ export function LanguageDropdown() {
|
||||
<div
|
||||
id='language-menu'
|
||||
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]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleLanguageChange(code)
|
||||
}}
|
||||
role='option'
|
||||
aria-selected={currentLang === code}
|
||||
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 ${
|
||||
currentLang === code ? 'bg-muted/60 font-medium text-primary' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className='text-base md:text-sm'>{lang.flag}</span>
|
||||
<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' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{languageEntries.map(([code, lang], index) => {
|
||||
const isSelected = currentLang === code
|
||||
const isHovered = hoveredIndex === index
|
||||
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleLanguageChange(code)
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(-1)}
|
||||
role='option'
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
'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',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
108
apps/docs/components/ui/sim-logo.tsx
Normal file
108
apps/docs/components/ui/sim-logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</div>
|
||||
|
||||
1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs**
|
||||
1. Navigieren Sie zu **Einstellungen → MCP-Server**
|
||||
2. Klicken Sie auf **Server erstellen**
|
||||
3. Geben Sie einen Namen und eine optionale Beschreibung ein
|
||||
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
|
||||
|
||||
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
|
||||
- **URL kopieren**: Die Server-URL für MCP-Clients abrufen
|
||||
|
||||
@@ -27,7 +27,7 @@ MCP-Server stellen Sammlungen von Tools bereit, die Ihre Agenten nutzen können.
|
||||
</div>
|
||||
|
||||
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**
|
||||
4. Geben Sie die Server-Konfigurationsdetails ein
|
||||
5. Speichern Sie die Konfiguration
|
||||
|
||||
@@ -56,6 +56,10 @@ Controls response randomness and creativity:
|
||||
- **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.
|
||||
|
||||
### 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
|
||||
|
||||
Your API key for the selected LLM provider. This is securely stored and used for authentication.
|
||||
|
||||
@@ -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} />
|
||||
</div>
|
||||
|
||||
1. Navigate to **Settings → Deployed MCPs**
|
||||
1. Navigate to **Settings → MCP Servers**
|
||||
2. Click **Create Server**
|
||||
3. Enter a name and optional description
|
||||
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
|
||||
|
||||
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
|
||||
- **Copy URL**: Get the server URL for MCP clients
|
||||
|
||||
@@ -27,7 +27,7 @@ MCP servers provide collections of tools that your agents can use. Configure the
|
||||
</div>
|
||||
|
||||
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**
|
||||
4. Enter the server configuration details
|
||||
5. Save the configuration
|
||||
|
||||
@@ -22,17 +22,17 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Create a workspace</td>
|
||||
<td>Click workspace dropdown → **New Workspace**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/create-workspace.mp4" alt="Create 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="/static/quick-reference/switch-workspace.mp4" alt="Switch workspaces" /></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="/static/quick-reference/invite.mp4" alt="Invite team members" /></td>
|
||||
<td><ActionVideo src="quick-reference/invite.mp4" alt="Invite team members" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rename a workspace</td>
|
||||
@@ -69,7 +69,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Reorder / move workflows</td>
|
||||
<td>Drag workflow up/down or onto a folder</td>
|
||||
<td><ActionVideo src="/static/quick-reference/reordering.mp4" alt="Reorder workflows" /></td>
|
||||
<td><ActionVideo src="quick-reference/reordering.mp4" alt="Reorder workflows" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Import a workflow</td>
|
||||
@@ -79,7 +79,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Multi-select workflows</td>
|
||||
<td>`Mod+Click` or `Shift+Click` workflows in sidebar</td>
|
||||
<td><ActionVideo src="/static/quick-reference/multiselect.mp4" alt="Multi-select workflows" /></td>
|
||||
<td><ActionVideo src="quick-reference/multiselect.mp4" alt="Multi-select workflows" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Open in new tab</td>
|
||||
@@ -144,17 +144,17 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Add a block</td>
|
||||
<td>Drag from Toolbar panel, or right-click canvas → **Add Block**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/add-block.mp4" alt="Add a block" /></td>
|
||||
<td><ActionVideo src="quick-reference/add-block.mp4" alt="Add a block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Multi-select blocks</td>
|
||||
<td>`Mod+Click` additional blocks, or shift-drag to draw selection box</td>
|
||||
<td><ActionVideo src="/static/quick-reference/multiselect-blocks.mp4" alt="Multi-select blocks" /></td>
|
||||
<td><ActionVideo src="quick-reference/multiselect-blocks.mp4" alt="Multi-select blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy blocks</td>
|
||||
<td>`Mod+C` with blocks selected</td>
|
||||
<td rowSpan={2}><ActionVideo src="/static/quick-reference/copy-paste.mp4" alt="Copy and paste blocks" /></td>
|
||||
<td rowSpan={2}><ActionVideo src="quick-reference/copy-paste.mp4" alt="Copy and paste blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Paste blocks</td>
|
||||
@@ -163,7 +163,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Duplicate blocks</td>
|
||||
<td>Right-click → **Duplicate**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/duplicate-block.mp4" alt="Duplicate blocks" /></td>
|
||||
<td><ActionVideo src="quick-reference/duplicate-block.mp4" alt="Duplicate blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete blocks</td>
|
||||
@@ -173,7 +173,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Rename a block</td>
|
||||
<td>Click block name in header, or edit in the Editor panel</td>
|
||||
<td><ActionVideo src="/static/quick-reference/rename-block.mp4" alt="Rename a block" /></td>
|
||||
<td><ActionVideo src="quick-reference/rename-block.mp4" alt="Rename a block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Enable/Disable a block</td>
|
||||
@@ -183,12 +183,12 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Toggle handle orientation</td>
|
||||
<td>Right-click → **Toggle Handles**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/toggle-handles.mp4" alt="Toggle handle orientation" /></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="/static/quick-reference/configure-block.mp4" alt="Configure a block" /></td>
|
||||
<td><ActionVideo src="quick-reference/configure-block.mp4" alt="Configure a block" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -203,17 +203,17 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Create a connection</td>
|
||||
<td>Drag from output handle to input handle</td>
|
||||
<td><ActionVideo src="/static/quick-reference/connect-blocks.mp4" alt="Connect blocks" /></td>
|
||||
<td><ActionVideo src="quick-reference/connect-blocks.mp4" alt="Connect blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete a connection</td>
|
||||
<td>Click edge to select → `Delete` key</td>
|
||||
<td><ActionVideo src="/static/quick-reference/delete-connection.mp4" alt="Delete connection" /></td>
|
||||
<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="/static/quick-reference/connection-tag.mp4" alt="Use connection tag" /></td>
|
||||
<td><ActionVideo src="quick-reference/connection-tag.mp4" alt="Use connection tag" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -228,7 +228,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Search toolbar</td>
|
||||
<td>`Mod+F`</td>
|
||||
<td><ActionVideo src="/static/quick-reference/search-toolbar.mp4" alt="Search toolbar" /></td>
|
||||
<td><ActionVideo src="quick-reference/search-toolbar.mp4" alt="Search toolbar" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Search everything</td>
|
||||
@@ -243,7 +243,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<tr>
|
||||
<td>Collapse/expand sidebar</td>
|
||||
<td>Click collapse button on sidebar</td>
|
||||
<td><ActionVideo src="/static/quick-reference/collapse-sidebar.mp4" alt="Collapse sidebar" /></td>
|
||||
<td><ActionVideo src="quick-reference/collapse-sidebar.mp4" alt="Collapse sidebar" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -337,7 +337,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy API endpoint</td>
|
||||
<td>Deploy tab → Copy API endpoint URL</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>
|
||||
@@ -367,7 +367,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reference an environment variable</td>
|
||||
<td>Use `{{ENV_VAR}}` syntax in block inputs</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>
|
||||
|
||||
@@ -47,6 +47,7 @@ Runs a browser automation task using BrowserUse
|
||||
| `save_browser_data` | boolean | No | Whether to save browser data |
|
||||
| `model` | string | No | LLM model to use \(default: gpt-4o\) |
|
||||
| `apiKey` | string | Yes | API key for BrowserUse API |
|
||||
| `profile_id` | string | No | Browser profile ID for persistent sessions \(cookies, login state\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -647,6 +647,42 @@ Retrieve a single ticket by ID from Intercom. Returns API-aligned fields only.
|
||||
| `ticketId` | string | ID of the retrieved ticket |
|
||||
| `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`
|
||||
|
||||
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 |
|
||||
| `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 |
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Access prediction markets and trade on Kalshi
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="kalshi"
|
||||
type="kalshi_v2"
|
||||
color="#09C285"
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,7 @@ Integrate Kalshi prediction markets into the workflow. Can get markets, market,
|
||||
|
||||
### `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
|
||||
|
||||
@@ -52,12 +52,12 @@ Retrieve a list of prediction markets from Kalshi with optional filtering
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `markets` | array | Array of market objects |
|
||||
| `paging` | object | Pagination cursor for fetching more results |
|
||||
| `markets` | array | Array of market objects with all API fields |
|
||||
| `cursor` | string | Pagination cursor for fetching more results |
|
||||
|
||||
### `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
|
||||
|
||||
@@ -69,11 +69,62 @@ Retrieve details of a specific prediction market by ticker
|
||||
|
||||
| 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`
|
||||
|
||||
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
|
||||
|
||||
@@ -90,11 +141,12 @@ Retrieve a list of events from Kalshi with optional filtering
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
Retrieve details of a specific event by ticker
|
||||
Retrieve details of a specific event by ticker (V2 - exact API response)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -107,11 +159,23 @@ Retrieve details of a specific event by ticker
|
||||
|
||||
| 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`
|
||||
|
||||
Retrieve your account balance and portfolio value from Kalshi
|
||||
Retrieve your account balance and portfolio value from Kalshi (V2 - exact API response)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -125,11 +189,12 @@ Retrieve your account balance and portfolio value from Kalshi
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
Retrieve your open positions from Kalshi
|
||||
Retrieve your open positions from Kalshi (V2 - exact API response)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -147,12 +212,13 @@ Retrieve your open positions from Kalshi
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `positions` | array | Array of position objects |
|
||||
| `paging` | object | Pagination cursor for fetching more results |
|
||||
| `market_positions` | array | Array of market position objects |
|
||||
| `event_positions` | array | Array of event position objects |
|
||||
| `cursor` | string | Pagination cursor for fetching more results |
|
||||
|
||||
### `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
|
||||
|
||||
@@ -170,12 +236,12 @@ Retrieve your orders from Kalshi with optional filtering
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `orders` | array | Array of order objects |
|
||||
| `paging` | object | Pagination cursor for fetching more results |
|
||||
| `orders` | array | Array of order objects with full API response fields |
|
||||
| `cursor` | string | Pagination cursor for fetching more results |
|
||||
|
||||
### `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
|
||||
|
||||
@@ -189,11 +255,44 @@ Retrieve details of a specific order by ID from Kalshi
|
||||
|
||||
| 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`
|
||||
|
||||
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
|
||||
|
||||
@@ -205,11 +304,18 @@ Retrieve the orderbook (yes and no bids) for a specific market
|
||||
|
||||
| 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`
|
||||
|
||||
Retrieve recent trades across all markets
|
||||
Retrieve recent trades with additional filtering options (V2 - includes trade_id and count_fp)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -222,12 +328,12 @@ Retrieve recent trades across all markets
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `trades` | array | Array of trade objects |
|
||||
| `paging` | object | Pagination cursor for fetching more results |
|
||||
| `trades` | array | Array of trade objects with trade_id and count_fp |
|
||||
| `cursor` | string | Pagination cursor for fetching more results |
|
||||
|
||||
### `kalshi_get_candlesticks`
|
||||
|
||||
Retrieve OHLC candlestick data for a specific market
|
||||
Retrieve OHLC candlestick data for a specific market (V2 - full API response)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -243,7 +349,8 @@ Retrieve OHLC candlestick data for a specific market
|
||||
|
||||
| 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`
|
||||
|
||||
@@ -266,12 +373,12 @@ Retrieve your portfolio
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `fills` | array | Array of fill/trade objects |
|
||||
| `paging` | object | Pagination cursor for fetching more results |
|
||||
| `fills` | array | Array of fill/trade objects with all API fields |
|
||||
| `cursor` | string | Pagination cursor for fetching more results |
|
||||
|
||||
### `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
|
||||
|
||||
@@ -283,11 +390,25 @@ Retrieve details of a specific market series by ticker
|
||||
|
||||
| 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`
|
||||
|
||||
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
|
||||
|
||||
@@ -298,11 +419,13 @@ Retrieve the current status of the Kalshi exchange (trading and exchange activit
|
||||
|
||||
| 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`
|
||||
|
||||
Create a new order on a Kalshi prediction market
|
||||
Create a new order on a Kalshi prediction market (V2 with full API response)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -332,11 +455,44 @@ Create a new order on a Kalshi prediction market
|
||||
|
||||
| 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`
|
||||
|
||||
Cancel an existing order on Kalshi
|
||||
Cancel an existing order on Kalshi (V2 with full API response)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -350,12 +506,46 @@ Cancel an existing order on Kalshi
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `order` | object | The canceled order object |
|
||||
| `reducedBy` | number | Number of contracts canceled |
|
||||
| `order` | object | The canceled 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 |
|
||||
| `reduced_by` | number | Number of contracts canceled |
|
||||
| `reduced_by_fp` | string | Number of contracts canceled in fixed-point format |
|
||||
|
||||
### `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
|
||||
|
||||
@@ -379,6 +569,63 @@ Modify the price or quantity of an existing order on Kalshi
|
||||
|
||||
| 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 |
|
||||
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
"shopify",
|
||||
"slack",
|
||||
"smtp",
|
||||
"spotify",
|
||||
"sqs",
|
||||
"ssh",
|
||||
"stagehand",
|
||||
|
||||
@@ -29,7 +29,7 @@ By using these documented API endpoints, you can seamlessly integrate Polymarket
|
||||
|
||||
## 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 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `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\) |
|
||||
| `ascending` | string | No | Sort direction \(true for ascending, false for descending\) |
|
||||
| `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 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -72,6 +87,28 @@ Retrieve details of a specific prediction market by ID or slug
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -81,7 +118,7 @@ Retrieve a list of events from Polymarket with optional filtering
|
||||
|
||||
| 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\) |
|
||||
| `ascending` | string | No | Sort direction \(true for ascending, false for descending\) |
|
||||
| `tagId` | string | No | Filter by tag ID |
|
||||
@@ -93,6 +130,21 @@ Retrieve a list of events from Polymarket with optional filtering
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -110,6 +162,24 @@ Retrieve details of a specific event by ID or slug
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -126,7 +196,12 @@ Retrieve available tags for filtering markets from Polymarket
|
||||
|
||||
| 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`
|
||||
|
||||
@@ -138,13 +213,28 @@ Search for markets, events, and profiles on Polymarket
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `query` | string | Yes | Search query term |
|
||||
| `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
|
||||
|
||||
| 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`
|
||||
|
||||
@@ -162,6 +252,21 @@ Retrieve series (related market groups) from Polymarket
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -178,6 +283,23 @@ Retrieve a specific series (related market group) by ID from Polymarket
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -194,6 +316,21 @@ Retrieve the order book summary for a specific token
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -246,7 +383,9 @@ Retrieve historical price data for a specific market token
|
||||
|
||||
| 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`
|
||||
|
||||
@@ -263,6 +402,7 @@ Retrieve the last trade price for a specific token
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `price` | string | Last trade price |
|
||||
| `side` | string | Side of the last trade \(BUY or SELL\) |
|
||||
|
||||
### `polymarket_get_spread`
|
||||
|
||||
@@ -278,7 +418,8 @@ Retrieve the bid-ask spread for a specific token
|
||||
|
||||
| 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`
|
||||
|
||||
@@ -305,13 +446,47 @@ Retrieve user positions from Polymarket
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `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
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
@@ -322,8 +497,13 @@ Retrieve trade history from Polymarket
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `user` | string | No | User wallet address to filter trades |
|
||||
| `market` | string | No | Market ID to filter trades |
|
||||
| `limit` | string | No | Number of results per page \(max 50\) |
|
||||
| `market` | string | No | Market/condition ID to filter trades \(mutually exclusive with eventId\) |
|
||||
| `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\) |
|
||||
|
||||
#### Output
|
||||
@@ -331,5 +511,141 @@ Retrieve trade history from Polymarket
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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
@@ -299,7 +299,7 @@ Upload a file to a Supabase 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"\) |
|
||||
| `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"\) |
|
||||
| `upsert` | boolean | No | If true, overwrites existing file \(default: false\) |
|
||||
| `apiKey` | string | Yes | Your Supabase service role secret key |
|
||||
@@ -309,7 +309,7 @@ Upload a file to a Supabase storage bucket
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `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`
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</div>
|
||||
|
||||
1. Navega a **Configuración → MCP implementados**
|
||||
1. Navega a **Configuración → Servidores MCP**
|
||||
2. Haz clic en **Crear servidor**
|
||||
3. Introduce un nombre y una descripción opcional
|
||||
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
|
||||
|
||||
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
|
||||
- **Copiar URL**: obtén la URL del servidor para clientes MCP
|
||||
|
||||
@@ -27,7 +27,7 @@ Los servidores MCP proporcionan colecciones de herramientas que tus agentes pued
|
||||
</div>
|
||||
|
||||
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**
|
||||
4. Introduce los detalles de configuración del servidor
|
||||
5. Guarda la configuración
|
||||
|
||||
@@ -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} />
|
||||
</div>
|
||||
|
||||
1. Accédez à **Paramètres → MCP déployés**
|
||||
1. Accédez à **Paramètres → Serveurs MCP**
|
||||
2. Cliquez sur **Créer un serveur**
|
||||
3. Saisissez un nom et une description facultative
|
||||
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
|
||||
|
||||
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
|
||||
- **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP
|
||||
|
||||
@@ -28,7 +28,7 @@ Les serveurs MCP fournissent des collections d'outils que vos agents peuvent uti
|
||||
</div>
|
||||
|
||||
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**
|
||||
4. Saisissez les détails de configuration du serveur
|
||||
5. Enregistrez la configuration
|
||||
|
||||
@@ -16,7 +16,7 @@ MCPサーバーは、ワークフローツールをまとめてグループ化
|
||||
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
|
||||
</div>
|
||||
|
||||
1. **設定 → デプロイ済みMCP**に移動します
|
||||
1. **設定 → MCP サーバー**に移動します
|
||||
2. **サーバーを作成**をクリックします
|
||||
3. 名前とオプションの説明を入力します
|
||||
4. MCPクライアントで使用するためにサーバーURLをコピーします
|
||||
@@ -78,7 +78,7 @@ mcp-remoteまたは他のHTTPベースのMCPトランスポートを使用する
|
||||
|
||||
## サーバー管理
|
||||
|
||||
**設定 → デプロイ済みMCP**のサーバー詳細ビューから、次のことができます:
|
||||
**設定 → MCP サーバー**のサーバー詳細ビューから、次のことができます:
|
||||
|
||||
- **ツールを表示**: サーバーに追加されたすべてのワークフローを確認
|
||||
- **URLをコピー**: MCPクライアント用のサーバーURLを取得
|
||||
|
||||
@@ -27,7 +27,7 @@ MCPサーバーはエージェントが使用できるツールのコレクシ
|
||||
</div>
|
||||
|
||||
1. ワークスペース設定に移動します
|
||||
2. **デプロイ済みMCP**セクションに移動します
|
||||
2. **MCP サーバー**セクションに移動します
|
||||
3. **MCPサーバーを追加**をクリックします
|
||||
4. サーバー設定の詳細を入力します
|
||||
5. 設定を保存します
|
||||
|
||||
@@ -16,7 +16,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
|
||||
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
|
||||
</div>
|
||||
|
||||
1. 进入 **设置 → 已部署的 MCPs**
|
||||
1. 进入 **设置 → MCP 服务器**
|
||||
2. 点击 **创建服务器**
|
||||
3. 输入名称和可选描述
|
||||
4. 复制服务器 URL 以在你的 MCP 客户端中使用
|
||||
@@ -78,7 +78,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
|
||||
|
||||
## 服务器管理
|
||||
|
||||
在 **设置 → 已部署的 MCPs** 的服务器详情页,你可以:
|
||||
在 **设置 → MCP 服务器** 的服务器详情页,你可以:
|
||||
|
||||
- **查看工具**:查看添加到服务器的所有工作流
|
||||
- **复制 URL**:获取 MCP 客户端的服务器 URL
|
||||
|
||||
@@ -27,7 +27,7 @@ MCP 服务器提供工具集合,供您的代理使用。您可以在工作区
|
||||
</div>
|
||||
|
||||
1. 进入您的工作区设置
|
||||
2. 前往 **Deployed MCPs** 部分
|
||||
2. 前往 **MCP Servers** 部分
|
||||
3. 点击 **Add MCP Server**
|
||||
4. 输入服务器配置信息
|
||||
5. 保存配置
|
||||
|
||||
@@ -8,6 +8,7 @@ import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
||||
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
||||
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
@@ -387,7 +388,12 @@ function resolveWorkflowVariables(
|
||||
if (type === 'number') {
|
||||
variableValue = Number(variableValue)
|
||||
} 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') {
|
||||
try {
|
||||
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`
|
||||
prologueLineCount++
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
if (v === undefined) {
|
||||
prologue += `const ${k} = undefined;\n`
|
||||
} else {
|
||||
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
|
||||
}
|
||||
prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n`
|
||||
prologueLineCount++
|
||||
}
|
||||
|
||||
@@ -762,11 +764,7 @@ export async function POST(req: NextRequest) {
|
||||
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
||||
prologueLineCount++
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
if (v === undefined) {
|
||||
prologue += `${k} = None\n`
|
||||
} else {
|
||||
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
|
||||
}
|
||||
prologue += `${k} = ${formatLiteralForCode(v, 'python')}\n`
|
||||
prologueLineCount++
|
||||
}
|
||||
const wrapped = [
|
||||
|
||||
@@ -16,6 +16,10 @@ mockKnowledgeSchemas()
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue({ role: 'owner' }),
|
||||
}))
|
||||
|
||||
describe('Knowledge Base API Route', () => {
|
||||
const mockAuth$ = mockAuth()
|
||||
|
||||
@@ -86,6 +90,7 @@ describe('Knowledge Base API Route', () => {
|
||||
const validKnowledgeBaseData = {
|
||||
name: 'Test Knowledge Base',
|
||||
description: 'Test description',
|
||||
workspaceId: 'test-workspace-id',
|
||||
chunkingConfig: {
|
||||
maxSize: 1024,
|
||||
minSize: 100,
|
||||
@@ -133,11 +138,25 @@ describe('Knowledge Base API Route', () => {
|
||||
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 () => {
|
||||
mockAuth$.mockAuthenticatedUser()
|
||||
|
||||
const invalidData = {
|
||||
name: 'Test KB',
|
||||
workspaceId: 'test-workspace-id',
|
||||
chunkingConfig: {
|
||||
maxSize: 100, // 100 tokens = 400 characters
|
||||
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 () => {
|
||||
mockAuth$.mockAuthenticatedUser()
|
||||
|
||||
const minimalData = { name: 'Test KB' }
|
||||
const minimalData = { name: 'Test KB', workspaceId: 'test-workspace-id' }
|
||||
const req = createMockRequest('POST', minimalData)
|
||||
const { POST } = await import('@/app/api/knowledge/route')
|
||||
const response = await POST(req)
|
||||
|
||||
@@ -19,7 +19,7 @@ const logger = createLogger('KnowledgeBaseAPI')
|
||||
const CreateKnowledgeBaseSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
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'),
|
||||
embeddingDimension: z.literal(1536).default(1536),
|
||||
chunkingConfig: z
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
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 { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
||||
|
||||
@@ -330,7 +330,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
try {
|
||||
return (
|
||||
<WorkflowPreview
|
||||
<PreviewWorkflow
|
||||
workflowState={template.state}
|
||||
height='100%'
|
||||
width='100%'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
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 { useStarTemplate } from '@/hooks/queries/templates'
|
||||
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]'
|
||||
>
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
<PreviewWorkflow
|
||||
workflowState={normalizedState}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full bg-[var(--surface-4)]' />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
@@ -13,13 +13,8 @@ import {
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
getLeftmostBlockId,
|
||||
PreviewEditor,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -32,13 +27,6 @@ interface TraceSpan {
|
||||
children?: TraceSpan[]
|
||||
}
|
||||
|
||||
interface BlockExecutionData {
|
||||
input: unknown
|
||||
output: unknown
|
||||
status: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
interface MigratedWorkflowState extends WorkflowState {
|
||||
_migrated: true
|
||||
_note?: string
|
||||
@@ -70,99 +58,29 @@ export function ExecutionSnapshot({
|
||||
onClose = () => {},
|
||||
}: ExecutionSnapshotProps) {
|
||||
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 [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsMenuOpen(false)
|
||||
setContextMenuBlockId(null)
|
||||
}, [])
|
||||
|
||||
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuBlockId(null)
|
||||
setMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleNodeContextMenu = useCallback(
|
||||
(blockId: string, mousePosition: { x: number; y: number }) => {
|
||||
setContextMenuBlockId(blockId)
|
||||
setMenuPosition(mousePosition)
|
||||
setIsMenuOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCopyExecutionId = useCallback(() => {
|
||||
navigator.clipboard.writeText(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
|
||||
|
||||
// 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 = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -226,44 +144,17 @@ export function ExecutionSnapshot({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className={cn(
|
||||
'flex overflow-hidden',
|
||||
!isModal && 'rounded-[4px] border border-[var(--border)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={(blockId) => {
|
||||
setPinnedBlockId(blockId)
|
||||
}}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
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>
|
||||
<Preview
|
||||
key={executionId}
|
||||
workflowState={workflowState}
|
||||
traceSpans={traceSpans}
|
||||
className={className}
|
||||
height={height}
|
||||
width={width}
|
||||
onCanvasContextMenu={handleCanvasContextMenu}
|
||||
showBorder={!isModal}
|
||||
autoSelectLeftmost
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -287,9 +178,6 @@ export function ExecutionSnapshot({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{contextMenuBlockId && (
|
||||
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
|
||||
)}
|
||||
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
@@ -304,7 +192,6 @@ export function ExecutionSnapshot({
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPinnedBlockId(null)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Star, User } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
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 { useStarTemplate } from '@/hooks/queries/templates'
|
||||
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]'
|
||||
>
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
<PreviewWorkflow
|
||||
workflowState={normalizedState}
|
||||
height={180}
|
||||
width='100%'
|
||||
@@ -214,6 +214,7 @@ function TemplateCardInner({
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
cursorStyle='pointer'
|
||||
lightweight
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
|
||||
@@ -25,18 +25,277 @@ function extractFieldValue(rawValue: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
type EmbedInfo = {
|
||||
url: string
|
||||
type: 'iframe' | 'video' | 'audio'
|
||||
aspectRatio?: string
|
||||
}
|
||||
|
||||
const EMBED_SCALE = 0.78
|
||||
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
|
||||
|
||||
function getTwitchParent(): string {
|
||||
return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract YouTube video ID from various YouTube URL formats
|
||||
* Get embed info for supported media platforms
|
||||
*/
|
||||
function getYouTubeVideoId(url: string): string | null {
|
||||
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]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -108,29 +367,57 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
|
||||
)
|
||||
},
|
||||
a: ({ href, children }: any) => {
|
||||
const videoId = href ? getYouTubeVideoId(href) : null
|
||||
if (videoId) {
|
||||
const embedInfo = href ? getEmbedInfo(href) : null
|
||||
if (embedInfo) {
|
||||
return (
|
||||
<span className='inline'>
|
||||
<span className='my-2 block w-full'>
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
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}
|
||||
</a>
|
||||
<span className='mt-1.5 block overflow-hidden rounded-md'>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title='YouTube video'
|
||||
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
loading='lazy'
|
||||
referrerPolicy='strict-origin-when-cross-origin'
|
||||
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
|
||||
className='aspect-video w-full'
|
||||
/>
|
||||
<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
|
||||
src={embedInfo.url}
|
||||
title='Media'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
loading='lazy'
|
||||
className='origin-top-left'
|
||||
style={{
|
||||
width: EMBED_INVERSE_SCALE,
|
||||
height: EMBED_INVERSE_SCALE,
|
||||
transform: `scale(${EMBED_SCALE})`,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
@@ -140,7 +427,7 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
|
||||
href={href}
|
||||
target='_blank'
|
||||
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}
|
||||
</a>
|
||||
@@ -159,6 +446,26 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
|
||||
{children}
|
||||
</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}
|
||||
@@ -171,7 +478,7 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<NoteBlockNodeData>) {
|
||||
const { type, config, name } = data
|
||||
const { type, name } = data
|
||||
|
||||
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
|
||||
blockId: id,
|
||||
@@ -248,8 +555,8 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative p-[8px]'>
|
||||
<div className='relative break-words'>
|
||||
<div className='relative overflow-hidden p-[8px]'>
|
||||
<div className='relative max-w-full break-all'>
|
||||
{isEmpty ? (
|
||||
<p className='text-[#868686] text-sm'>Add note...</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
@@ -17,11 +17,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
getLeftmostBlockId,
|
||||
PreviewEditor,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { Versions } from './components'
|
||||
@@ -59,8 +55,6 @@ export function GeneralDeploy({
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false)
|
||||
const [showPromoteDialog, setShowPromoteDialog] = 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 [versionToPromote, setVersionToPromote] = useState<number | null>(null)
|
||||
|
||||
@@ -135,19 +129,6 @@ export function GeneralDeploy({
|
||||
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
|
||||
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) {
|
||||
return (
|
||||
<div className='space-y-[12px]'>
|
||||
@@ -205,7 +186,7 @@ export function GeneralDeploy({
|
||||
{workflowToShow ? (
|
||||
<>
|
||||
<div className='[&_*]:!cursor-default h-full w-full cursor-default'>
|
||||
<WorkflowPreview
|
||||
<PreviewWorkflow
|
||||
workflowState={workflowToShow}
|
||||
height='100%'
|
||||
width='100%'
|
||||
@@ -306,46 +287,15 @@ export function GeneralDeploy({
|
||||
</Modal>
|
||||
|
||||
{workflowToShow && (
|
||||
<Modal
|
||||
open={showExpandedPreview}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setExpandedSelectedBlockId(null)
|
||||
}
|
||||
setShowExpandedPreview(open)
|
||||
}}
|
||||
>
|
||||
<Modal open={showExpandedPreview} onOpenChange={setShowExpandedPreview}>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>
|
||||
{previewMode === 'selected' && selectedVersionInfo
|
||||
? selectedVersionInfo.name || `v${selectedVersion}`
|
||||
: 'Live Workflow'}
|
||||
</ModalHeader>
|
||||
<ModalBody className='!p-0 min-h-0 flex-1'>
|
||||
<div className='flex h-full w-full overflow-hidden'>
|
||||
<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 className='!p-0 min-h-0 flex-1 overflow-hidden'>
|
||||
<Preview workflowState={workflowToShow} autoSelectLeftmost />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -435,7 +435,7 @@ export function McpDeploy({
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<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>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
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 {
|
||||
useCreateTemplate,
|
||||
@@ -439,13 +439,14 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}}
|
||||
aria-hidden='true'
|
||||
>
|
||||
<WorkflowPreview
|
||||
<PreviewWorkflow
|
||||
workflowState={workflowState}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -477,7 +478,7 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowPreview
|
||||
<PreviewWorkflow
|
||||
key={`template-preview-${existingTemplate.id}`}
|
||||
workflowState={workflowState}
|
||||
height='100%'
|
||||
|
||||
@@ -39,6 +39,8 @@ import { normalizeName } from '@/executor/constants'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||
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')
|
||||
|
||||
@@ -212,7 +214,6 @@ export const Code = memo(function Code({
|
||||
const handleStreamStartRef = useRef<() => void>(() => {})
|
||||
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
|
||||
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
|
||||
const hasEditedSinceFocusRef = useRef(false)
|
||||
const codeRef = useRef(code)
|
||||
codeRef.current = code
|
||||
|
||||
@@ -220,8 +221,12 @@ export const Code = memo(function Code({
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const blockType = useWorkflowStore(
|
||||
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
|
||||
)
|
||||
|
||||
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
|
||||
const isFunctionCode = blockType === 'function' && subBlockId === 'code'
|
||||
|
||||
const trimmedCode = code.trim()
|
||||
const containsReferencePlaceholders =
|
||||
@@ -296,6 +301,15 @@ export const Code = memo(function Code({
|
||||
const updatePromptValue = wandHook?.updatePromptValue || (() => {})
|
||||
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, {
|
||||
isStreaming: isAiStreaming,
|
||||
onStreamingEnd: () => {
|
||||
@@ -347,9 +361,10 @@ export const Code = memo(function Code({
|
||||
setCode(generatedCode)
|
||||
if (!isPreview && !disabled) {
|
||||
setStoreValue(generatedCode)
|
||||
recordReplace(generatedCode)
|
||||
}
|
||||
}
|
||||
}, [isPreview, disabled, setStoreValue])
|
||||
}, [disabled, isPreview, recordReplace, setStoreValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return
|
||||
@@ -492,7 +507,7 @@ export const Code = memo(function Code({
|
||||
|
||||
setCode(newValue)
|
||||
setStoreValue(newValue)
|
||||
hasEditedSinceFocusRef.current = true
|
||||
recordChange(newValue)
|
||||
const newCursorPosition = dropPosition + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
@@ -521,7 +536,7 @@ export const Code = memo(function Code({
|
||||
if (!isPreview && !readOnly) {
|
||||
setCode(newValue)
|
||||
emitTagSelection(newValue)
|
||||
hasEditedSinceFocusRef.current = true
|
||||
recordChange(newValue)
|
||||
}
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
@@ -539,7 +554,7 @@ export const Code = memo(function Code({
|
||||
if (!isPreview && !readOnly) {
|
||||
setCode(newValue)
|
||||
emitTagSelection(newValue)
|
||||
hasEditedSinceFocusRef.current = true
|
||||
recordChange(newValue)
|
||||
}
|
||||
setShowEnvVars(false)
|
||||
|
||||
@@ -625,9 +640,9 @@ export const Code = memo(function Code({
|
||||
const handleValueChange = useCallback(
|
||||
(newCode: string) => {
|
||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||
hasEditedSinceFocusRef.current = true
|
||||
setCode(newCode)
|
||||
setStoreValue(newCode)
|
||||
recordChange(newCode)
|
||||
|
||||
const textarea = editorRef.current?.querySelector('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(
|
||||
@@ -657,21 +672,39 @@ export const Code = memo(function Code({
|
||||
}
|
||||
if (isAiStreaming) {
|
||||
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.stopPropagation()
|
||||
undo()
|
||||
return
|
||||
}
|
||||
if (isRedo) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
redo()
|
||||
}
|
||||
},
|
||||
[isAiStreaming]
|
||||
[isAiStreaming, isFunctionCode, redo, undo]
|
||||
)
|
||||
|
||||
const handleEditorFocus = useCallback(() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
startSession(codeRef.current)
|
||||
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
|
||||
setShowTags(true)
|
||||
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.
|
||||
@@ -791,6 +824,7 @@ export const Code = memo(function Code({
|
||||
onValueChange={handleValueChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleEditorFocus}
|
||||
onBlur={handleEditorBlur}
|
||||
highlight={highlightCode}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
/>
|
||||
|
||||
@@ -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 { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
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 type { SubBlockType } from '@/blocks/types'
|
||||
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||
@@ -458,7 +458,7 @@ export function Editor() {
|
||||
) : childWorkflowState ? (
|
||||
<>
|
||||
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
|
||||
<WorkflowPreview
|
||||
<PreviewWorkflow
|
||||
workflowState={childWorkflowState}
|
||||
height={160}
|
||||
width='100%'
|
||||
@@ -466,6 +466,7 @@ export function Editor() {
|
||||
defaultZoom={0.6}
|
||||
fitPadding={0.15}
|
||||
cursorStyle='grab'
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
|
||||
/** Extended edge props with optional handle identifiers */
|
||||
interface WorkflowEdgeProps extends EdgeProps {
|
||||
sourceHandle?: string | null
|
||||
targetHandle?: string | null
|
||||
@@ -90,15 +91,17 @@ const WorkflowEdgeComponent = ({
|
||||
if (edgeDiffStatus === 'deleted') {
|
||||
color = 'var(--text-error)'
|
||||
opacity = 0.7
|
||||
} else if (isErrorEdge) {
|
||||
color = 'var(--text-error)'
|
||||
} else if (edgeDiffStatus === 'new') {
|
||||
color = 'var(--brand-tertiary-2)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
// 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)'
|
||||
} else if (edgeRunStatus === 'error') {
|
||||
color = 'var(--text-error)'
|
||||
} else if (isErrorEdge) {
|
||||
// Error edges that weren't taken stay red
|
||||
color = 'var(--text-error)'
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1641,51 +1641,36 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [screenToFlowPosition, handleToolbarDrop])
|
||||
|
||||
/**
|
||||
* Focus canvas on changed blocks when diff appears
|
||||
* Focuses on new/edited blocks rather than fitting the entire workflow
|
||||
* Focus canvas on changed blocks when diff appears.
|
||||
*/
|
||||
const pendingZoomBlockIdsRef = useRef<Set<string> | null>(null)
|
||||
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(() => {
|
||||
// Only focus when diff transitions from not ready to ready
|
||||
if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) {
|
||||
// Diff just became ready - record blocks to zoom to
|
||||
const changedBlockIds = [
|
||||
...(diffAnalysis.new_blocks || []),
|
||||
...(diffAnalysis.edited_blocks || []),
|
||||
]
|
||||
|
||||
if (changedBlockIds.length > 0) {
|
||||
const allNodes = getNodes()
|
||||
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 {
|
||||
logger.info('Diff ready - no changed nodes found, fitting all')
|
||||
requestAnimationFrame(() => {
|
||||
fitViewToBounds({ padding: 0.1, duration: 600 })
|
||||
})
|
||||
}
|
||||
pendingZoomBlockIdsRef.current = new Set(changedBlockIds)
|
||||
} else {
|
||||
logger.info('Diff ready - no changed blocks, fitting all')
|
||||
// No specific blocks to focus on, fit all after a frame
|
||||
pendingZoomBlockIdsRef.current = null
|
||||
requestAnimationFrame(() => {
|
||||
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
|
||||
}, [isDiffReady, diffAnalysis, fitViewToBounds, getNodes])
|
||||
}, [isDiffReady, diffAnalysis, fitViewToBounds])
|
||||
|
||||
/** Displays trigger warning notifications. */
|
||||
useEffect(() => {
|
||||
@@ -2093,6 +2078,48 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
}, [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. */
|
||||
useEffect(() => {
|
||||
const handleRemoveFromSubflow = (event: Event) => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewContextMenu } from './preview-context-menu'
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewEditor } from './preview-editor'
|
||||
@@ -6,12 +6,24 @@ import {
|
||||
ArrowUp,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
RepeatIcon,
|
||||
SplitIcon,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
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 { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
|
||||
@@ -22,15 +34,42 @@ import {
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} 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 { 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 { getBlock } from '@/blocks'
|
||||
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||
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
|
||||
@@ -123,44 +162,31 @@ function formatInlineValue(value: unknown): string {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
interface ExecutionDataSectionProps {
|
||||
interface CollapsibleSectionProps {
|
||||
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
|
||||
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)
|
||||
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
|
||||
* Collapsible section wrapper for organizing preview editor content
|
||||
*/
|
||||
function ExecutionDataSection({
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
data,
|
||||
defaultExpanded = false,
|
||||
children,
|
||||
isEmpty = false,
|
||||
emptyMessage = 'No data',
|
||||
isError = false,
|
||||
wrapText = true,
|
||||
searchQuery,
|
||||
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 === ''
|
||||
}: CollapsibleSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
|
||||
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
|
||||
className='group flex cursor-pointer items-center justify-between'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -199,20 +225,10 @@ function ExecutionDataSection({
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<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 onContextMenu={onContextMenu} ref={contentRef}>
|
||||
<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>
|
||||
children
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -261,9 +277,12 @@ function ConnectionsSection({
|
||||
const [expandedVariables, setExpandedVariables] = 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(() => {
|
||||
setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
|
||||
}, [connections])
|
||||
setExpandedBlocks(new Set(connectionIds.split(',').filter(Boolean)))
|
||||
}, [connectionIds])
|
||||
|
||||
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 config = isLoop ? SUBFLOW_CONFIG.loop : SUBFLOW_CONFIG.parallel
|
||||
|
||||
// Determine current type
|
||||
const currentType = isLoop
|
||||
? loop?.loopType || (block.data?.loopType as string) || 'for'
|
||||
: parallel?.parallelType || (block.data?.parallelType as string) || 'count'
|
||||
|
||||
// Build type options for combobox - matches SubflowEditor
|
||||
const typeOptions = Object.entries(config.typeLabels).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
// Determine mode
|
||||
const isCountMode = currentType === 'for' || currentType === 'count'
|
||||
const isConditionMode = currentType === 'while' || currentType === 'doWhile'
|
||||
|
||||
// Get iterations value
|
||||
const iterations = isLoop
|
||||
? (loop?.iterations ?? (block.data?.count as number) ?? 5)
|
||||
: (parallel?.count ?? (block.data?.count as number) ?? 1)
|
||||
|
||||
// Get collection/condition value
|
||||
const getEditorValue = (): string => {
|
||||
if (isConditionMode && isLoop) {
|
||||
if (currentType === 'while') {
|
||||
@@ -589,7 +603,6 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
|
||||
|
||||
const editorValue = getEditorValue()
|
||||
|
||||
// Get label for configuration field - matches SubflowEditor exactly
|
||||
const getConfigLabel = (): string => {
|
||||
if (isCountMode) {
|
||||
return `${isLoop ? 'Loop' : 'Parallel'} Iterations`
|
||||
@@ -601,7 +614,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -703,6 +716,8 @@ interface PreviewEditorProps {
|
||||
isExecutionMode?: boolean
|
||||
/** Optional close handler - if not provided, no close button is shown */
|
||||
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) */
|
||||
@@ -725,8 +740,8 @@ function PreviewEditorContent({
|
||||
parallels,
|
||||
isExecutionMode = false,
|
||||
onClose,
|
||||
onDrillDown,
|
||||
}: PreviewEditorProps) {
|
||||
// Convert Record<string, Variable> to Array<Variable> for iteration
|
||||
const normalizedWorkflowVariables = useMemo(() => {
|
||||
if (!workflowVariables) return []
|
||||
return Object.values(workflowVariables)
|
||||
@@ -735,10 +750,39 @@ function PreviewEditorContent({
|
||||
const blockConfig = getBlock(block.type) as BlockConfig | undefined
|
||||
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 subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Connections resize state
|
||||
const [connectionsHeight, setConnectionsHeight] = useState(DEFAULT_CONNECTIONS_HEIGHT)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startYRef = useRef<number>(0)
|
||||
@@ -846,10 +890,8 @@ function PreviewEditorContent({
|
||||
if (!isResizing) return
|
||||
|
||||
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
|
||||
|
||||
// Clamp height between fixed min and max for stable behavior
|
||||
newHeight = Math.max(MIN_CONNECTIONS_HEIGHT, Math.min(MAX_CONNECTIONS_HEIGHT, newHeight))
|
||||
setConnectionsHeight(newHeight)
|
||||
}
|
||||
@@ -871,7 +913,6 @@ function PreviewEditorContent({
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
// Determine if connections are at minimum height (collapsed state)
|
||||
const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5
|
||||
|
||||
const blockNameToId = useMemo(() => {
|
||||
@@ -891,7 +932,7 @@ function PreviewEditorContent({
|
||||
if (!allBlockExecutions || !workflowBlocks) 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('.')
|
||||
if (parts.length < 1) return undefined
|
||||
|
||||
@@ -1007,12 +1048,10 @@ function PreviewEditorContent({
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
|
||||
// Check if this is a subflow block (loop or parallel)
|
||||
const isSubflow = block.type === 'loop' || block.type === 'parallel'
|
||||
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
|
||||
const parallelConfig = block.type === 'parallel' ? parallels?.[block.id] : undefined
|
||||
|
||||
// Handle subflow blocks
|
||||
if (isSubflow) {
|
||||
const isLoop = block.type === 'loop'
|
||||
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-1 overflow-y-auto overflow-x-hidden'>
|
||||
<div className='readonly-preview px-[8px]'>
|
||||
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
|
||||
<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>
|
||||
<style>{READONLY_PREVIEW_STYLES}</style>
|
||||
<SubflowConfigDisplay block={block} loop={loopConfig} parallel={parallelConfig} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1095,8 +1114,6 @@ function PreviewEditorContent({
|
||||
|
||||
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
|
||||
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 (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
if (
|
||||
@@ -1145,7 +1162,7 @@ function PreviewEditorContent({
|
||||
|
||||
{/* Content area */}
|
||||
<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 className='flex-1 overflow-y-auto overflow-x-hidden'>
|
||||
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
|
||||
@@ -1159,91 +1176,154 @@ function PreviewEditorContent({
|
||||
</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 */}
|
||||
{(executionData.status || executionData.durationMs !== undefined) && (
|
||||
<div className='flex items-center justify-between'>
|
||||
{executionData.status && (
|
||||
<Badge variant={statusVariant} size='sm' dot>
|
||||
<span className='capitalize'>{executionData.status}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{executionData.durationMs !== undefined && (
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatDuration(executionData.durationMs, { precision: 2 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Execution Status & Duration Header */}
|
||||
{executionData && (executionData.status || executionData.durationMs !== undefined) && (
|
||||
<div className='flex min-w-0 items-center justify-between overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
|
||||
{executionData.status && (
|
||||
<Badge variant={statusVariant} size='sm' dot>
|
||||
<span className='capitalize'>{executionData.status}</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Divider between Status/Duration and Input/Output */}
|
||||
{(executionData.status || executionData.durationMs !== undefined) &&
|
||||
(executionData.input !== undefined || executionData.output !== undefined) && (
|
||||
<div className='border-[var(--border)] border-t border-dashed' />
|
||||
)}
|
||||
|
||||
{/* Input Section */}
|
||||
{executionData.input !== undefined && (
|
||||
<ExecutionDataSection
|
||||
title='Input'
|
||||
data={executionData.input}
|
||||
wrapText={wrapText}
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
contentRef={contentRef}
|
||||
onContextMenu={handleExecutionContextMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Divider between Input and Output */}
|
||||
{executionData.input !== undefined && executionData.output !== undefined && (
|
||||
<div className='border-[var(--border)] border-t border-dashed' />
|
||||
)}
|
||||
|
||||
{/* Output Section */}
|
||||
{executionData.output !== undefined && (
|
||||
<ExecutionDataSection
|
||||
title={executionData.status === 'error' ? 'Error' : 'Output'}
|
||||
data={executionData.output}
|
||||
isError={executionData.status === 'error'}
|
||||
wrapText={wrapText}
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
contentRef={contentRef}
|
||||
onContextMenu={handleExecutionContextMenu}
|
||||
/>
|
||||
{executionData.durationMs !== undefined && (
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatDuration(executionData.durationMs, { precision: 2 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Input Section - Collapsible */}
|
||||
{executionData?.input !== undefined && (
|
||||
<CollapsibleSection
|
||||
title='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}
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Output Section - Collapsible, expanded by default */}
|
||||
{executionData?.output !== undefined && (
|
||||
<CollapsibleSection
|
||||
title={executionData.status === 'error' ? 'Error' : 'Output'}
|
||||
defaultExpanded={true}
|
||||
isEmpty={
|
||||
formatValueAsJson(executionData.output) === '—' ||
|
||||
formatValueAsJson(executionData.output) === ''
|
||||
}
|
||||
emptyMessage='No output data'
|
||||
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}
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
</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>
|
||||
<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 */}
|
||||
<div className='readonly-preview px-[8px] py-[8px]'>
|
||||
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
|
||||
<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>
|
||||
<div className='readonly-preview px-[8px] pt-[12px] pb-[8px]'>
|
||||
<style>{READONLY_PREVIEW_STYLES}</style>
|
||||
{visibleSubBlocks.length > 0 ? (
|
||||
<div className='flex flex-col'>
|
||||
{visibleSubBlocks.map((subBlockConfig, index) => (
|
||||
@@ -1349,7 +1429,7 @@ function PreviewEditorContent({
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
<SnapshotContextMenu
|
||||
<PreviewContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { type CSSProperties, memo, useMemo } from 'react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import {
|
||||
@@ -23,6 +23,27 @@ interface SubBlockValueEntry {
|
||||
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 {
|
||||
type: string
|
||||
name: string
|
||||
@@ -35,6 +56,8 @@ interface WorkflowPreviewBlockData {
|
||||
executionStatus?: ExecutionStatus
|
||||
/** Subblock values from the workflow state */
|
||||
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
|
||||
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
|
||||
|
||||
// Priority 2: Extract from inline schema (legacy format)
|
||||
const schema = t.schema as Record<string, unknown> | undefined
|
||||
if (schema?.function && typeof schema.function === 'object') {
|
||||
const fn = schema.function as Record<string, unknown>
|
||||
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
|
||||
if (fn?.name && typeof fn.name === 'string') return fn.name
|
||||
|
||||
// Priority 4: Resolve built-in tool blocks from registry
|
||||
if (
|
||||
typeof t.type === 'string' &&
|
||||
t.type !== 'custom-tool' &&
|
||||
@@ -204,21 +223,16 @@ function resolveToolsDisplay(
|
||||
* - Shows '-' for other selector types that need hydration
|
||||
*/
|
||||
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
|
||||
// Mask password fields
|
||||
const isPasswordField = subBlock?.password === true
|
||||
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||
|
||||
// Resolve various display names (synchronous access, matching WorkflowBlock priority)
|
||||
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
|
||||
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
|
||||
const toolsDisplay = resolveToolsDisplay(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)
|
||||
|
||||
// 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 displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
|
||||
|
||||
@@ -258,6 +272,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
isPreviewSelected = false,
|
||||
executionStatus,
|
||||
subBlockValues,
|
||||
lightweight = false,
|
||||
} = data
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
@@ -268,44 +283,68 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
)
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
if (!subBlockValues) return {}
|
||||
if (lightweight || !subBlockValues) return {}
|
||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||
acc[key] = extractValue(entry)
|
||||
return acc
|
||||
}, {})
|
||||
}, [subBlockValues])
|
||||
}, [subBlockValues, lightweight])
|
||||
|
||||
const visibleSubBlocks = useMemo(() => {
|
||||
if (!blockConfig?.subBlocks) return []
|
||||
|
||||
const isStarterOrTrigger =
|
||||
blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
|
||||
const effectiveTrigger = isTrigger || type === 'starter'
|
||||
|
||||
return blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden) return false
|
||||
if (subBlock.hideFromPreview) return false
|
||||
if (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
|
||||
// Handle trigger mode visibility
|
||||
if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false
|
||||
if (effectiveTrigger) {
|
||||
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)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check condition visibility
|
||||
if (!subBlock.condition) return true
|
||||
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(() => {
|
||||
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 raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
|
||||
|
||||
@@ -325,21 +364,24 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse, use fallback
|
||||
/* empty */
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: 'if', title: 'if', value: '' },
|
||||
{ id: 'else', title: 'else', value: '' },
|
||||
]
|
||||
}, [type, rawValues])
|
||||
return defaultRows
|
||||
}, [type, rawValues, lightweight])
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
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 raw = typeof routesValue === 'string' ? routesValue : undefined
|
||||
|
||||
@@ -357,11 +399,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse, use fallback
|
||||
/* empty */
|
||||
}
|
||||
|
||||
return [{ id: 'route1', value: '' }]
|
||||
}, [type, rawValues])
|
||||
return defaultRows
|
||||
}, [type, rawValues, lightweight])
|
||||
|
||||
if (!blockConfig) {
|
||||
return null
|
||||
@@ -379,9 +421,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
? routerRows.length > 0 || 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 hasSuccess = executionStatus === 'success'
|
||||
|
||||
@@ -406,7 +445,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
type='target'
|
||||
position={horizontalHandles ? Position.Left : Position.Top}
|
||||
id='target'
|
||||
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
|
||||
className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
|
||||
style={
|
||||
horizontalHandles
|
||||
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
|
||||
@@ -439,36 +478,37 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{type === 'condition' ? (
|
||||
// Condition block: render condition rows
|
||||
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' ? (
|
||||
// Router block: render context + route rows
|
||||
<>
|
||||
<SubBlockRow
|
||||
key='context'
|
||||
title='Context'
|
||||
value={getDisplayValue(rawValues.context)}
|
||||
value={lightweight ? undefined : getDisplayValue(rawValues.context)}
|
||||
/>
|
||||
{routerRows.map((route, index) => (
|
||||
<SubBlockRow
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
value={getDisplayValue(route.value)}
|
||||
value={lightweight ? undefined : getDisplayValue(route.value)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Standard blocks: render visible subblocks
|
||||
visibleSubBlocks.map((subBlock) => {
|
||||
const rawValue = rawValues[subBlock.id]
|
||||
const rawValue = lightweight ? undefined : rawValues[subBlock.id]
|
||||
return (
|
||||
<SubBlockRow
|
||||
key={subBlock.id}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
value={lightweight ? undefined : getDisplayValue(rawValue)}
|
||||
subBlock={lightweight ? undefined : subBlock}
|
||||
rawValue={rawValue}
|
||||
/>
|
||||
)
|
||||
@@ -479,27 +519,101 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source handle */}
|
||||
<Handle
|
||||
type='source'
|
||||
position={horizontalHandles ? Position.Right : Position.Bottom}
|
||||
id='source'
|
||||
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
|
||||
style={
|
||||
horizontalHandles
|
||||
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
|
||||
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
|
||||
}
|
||||
/>
|
||||
{/* 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
|
||||
type='source'
|
||||
position={horizontalHandles ? Position.Right : Position.Bottom}
|
||||
id='source'
|
||||
className={horizontalHandles ? HANDLE_STYLES.right : HANDLE_STYLES.vertical}
|
||||
style={
|
||||
horizontalHandles
|
||||
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
|
||||
: { 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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
prevProps: NodeProps<WorkflowPreviewBlockData>,
|
||||
nextProps: NodeProps<WorkflowPreviewBlockData>
|
||||
): boolean {
|
||||
// Check primitive props first (fast path)
|
||||
if (
|
||||
prevProps.id !== nextProps.id ||
|
||||
prevProps.data.type !== nextProps.data.type ||
|
||||
@@ -508,38 +622,41 @@ function shouldSkipPreviewBlockRender(
|
||||
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
|
||||
prevProps.data.enabled !== nextProps.data.enabled ||
|
||||
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
|
||||
}
|
||||
|
||||
// Compare subBlockValues by reference first
|
||||
/** Skip subBlockValues comparison in lightweight mode */
|
||||
if (nextProps.data.lightweight) return true
|
||||
|
||||
const prevValues = prevProps.data.subBlockValues
|
||||
const nextValues = nextProps.data.subBlockValues
|
||||
|
||||
if (prevValues === nextValues) {
|
||||
return true
|
||||
}
|
||||
if (prevValues === nextValues) return true
|
||||
if (!prevValues || !nextValues) return false
|
||||
|
||||
if (!prevValues || !nextValues) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Shallow compare keys and values
|
||||
const prevKeys = Object.keys(prevValues)
|
||||
const nextKeys = Object.keys(nextValues)
|
||||
|
||||
if (prevKeys.length !== nextKeys.length) {
|
||||
return false
|
||||
}
|
||||
if (prevKeys.length !== nextKeys.length) return false
|
||||
|
||||
for (const key of prevKeys) {
|
||||
if (prevValues[key] !== nextValues[key]) {
|
||||
return false
|
||||
}
|
||||
if (prevValues[key] !== nextValues[key]) return false
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewBlock } from './block'
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewSubflow } from './subflow'
|
||||
@@ -5,6 +5,9 @@ import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
|
||||
/** Execution status for subflows in preview mode */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
interface WorkflowPreviewSubflowData {
|
||||
name: string
|
||||
width?: number
|
||||
@@ -12,6 +15,10 @@ interface WorkflowPreviewSubflowData {
|
||||
kind: 'loop' | 'parallel'
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
/** Execution status for highlighting the subflow container */
|
||||
executionStatus?: ExecutionStatus
|
||||
/** Skips expensive computations for thumbnails/template previews (unused in subflow, for consistency) */
|
||||
lightweight?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +27,7 @@ interface WorkflowPreviewSubflowData {
|
||||
* or interactive features.
|
||||
*/
|
||||
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false, executionStatus } = data
|
||||
|
||||
const isLoop = kind === 'loop'
|
||||
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
|
||||
@@ -35,6 +42,9 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
const rightHandleClass =
|
||||
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
|
||||
|
||||
const hasError = executionStatus === 'error'
|
||||
const hasSuccess = executionStatus === 'success'
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
|
||||
@@ -43,10 +53,18 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
height,
|
||||
}}
|
||||
>
|
||||
{/* Selection ring overlay */}
|
||||
{/* Selection ring overlay (takes priority over execution rings) */}
|
||||
{isPreviewSelected && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
|
||||
)}
|
||||
{/* Success ring overlay (only shown if not selected) */}
|
||||
{!isPreviewSelected && hasSuccess && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-tertiary-2)]' />
|
||||
)}
|
||||
{/* Error ring overlay (only shown if not selected) */}
|
||||
{!isPreviewSelected && hasError && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--text-error)]' />
|
||||
)}
|
||||
|
||||
{/* Target handle on left (input to the subflow) */}
|
||||
<Handle
|
||||
@@ -110,4 +128,4 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowPreviewSubflow = memo(WorkflowPreviewSubflowInner)
|
||||
export const PreviewSubflow = memo(WorkflowPreviewSubflowInner)
|
||||
@@ -0,0 +1 @@
|
||||
export { getLeftmostBlockId, PreviewWorkflow } from './preview-workflow'
|
||||
@@ -0,0 +1,613 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import ReactFlow, {
|
||||
ConnectionLineType,
|
||||
type Edge,
|
||||
type EdgeTypes,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
|
||||
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('PreviewWorkflow')
|
||||
|
||||
/**
|
||||
* Gets block dimensions for preview purposes.
|
||||
* For containers, uses stored dimensions or defaults.
|
||||
* For regular blocks, uses stored height or estimates based on type.
|
||||
*/
|
||||
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
return {
|
||||
width: block.data?.width
|
||||
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
|
||||
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: block.data?.height
|
||||
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
|
||||
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.height) {
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
return estimateBlockDimensions(block.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions and sizes.
|
||||
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
|
||||
*/
|
||||
function calculateContainerDimensions(
|
||||
containerId: string,
|
||||
blocks: Record<string, BlockState>
|
||||
): { width: number; height: number } {
|
||||
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
|
||||
|
||||
if (childBlocks.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
for (const child of childBlocks) {
|
||||
if (!child?.position) continue
|
||||
|
||||
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
|
||||
|
||||
maxRight = Math.max(maxRight, child.position.x + childWidth)
|
||||
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the leftmost block ID from a workflow state.
|
||||
* Excludes subflow containers (loop/parallel) from consideration.
|
||||
* @param workflowState - The workflow state to search
|
||||
* @returns The ID of the leftmost block, or null if no blocks exist
|
||||
*/
|
||||
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
|
||||
if (!workflowState?.blocks) return null
|
||||
|
||||
let leftmostId: string | null = null
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
|
||||
if (!block || block.type === 'loop' || block.type === 'parallel') continue
|
||||
const x = block.position?.x ?? Number.POSITIVE_INFINITY
|
||||
if (x < minX) {
|
||||
minX = x
|
||||
leftmostId = blockId
|
||||
}
|
||||
}
|
||||
|
||||
return leftmostId
|
||||
}
|
||||
|
||||
/** Execution status for edges/nodes in the preview */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
/** Calculates absolute position for blocks, handling nested subflows */
|
||||
function calculateAbsolutePosition(
|
||||
block: BlockState,
|
||||
blocks: Record<string, BlockState>
|
||||
): { x: number; y: number } {
|
||||
if (!block.data?.parentId) {
|
||||
return block.position
|
||||
}
|
||||
|
||||
const parentBlock = blocks[block.data.parentId]
|
||||
if (!parentBlock) {
|
||||
logger.warn(`Parent block not found for child block`)
|
||||
return block.position
|
||||
}
|
||||
|
||||
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
|
||||
return {
|
||||
x: parentAbsolutePosition.x + block.position.x,
|
||||
y: parentAbsolutePosition.y + block.position.y,
|
||||
}
|
||||
}
|
||||
|
||||
interface PreviewWorkflowProps {
|
||||
workflowState: WorkflowState
|
||||
className?: string
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
isPannable?: boolean
|
||||
defaultPosition?: { x: number; y: number }
|
||||
defaultZoom?: number
|
||||
fitPadding?: number
|
||||
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Callback when a node is right-clicked */
|
||||
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Callback when the canvas (empty area) is clicked */
|
||||
onPaneClick?: () => void
|
||||
/** Cursor style to show when hovering the canvas */
|
||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
||||
executedBlocks?: Record<string, { status: string }>
|
||||
/** Currently selected block ID for highlighting */
|
||||
selectedBlockId?: string | null
|
||||
/** Skips expensive subblock computations for thumbnails/template previews */
|
||||
lightweight?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview node types using minimal components without hooks or store subscriptions.
|
||||
* This prevents interaction issues while allowing canvas panning and node clicking.
|
||||
*/
|
||||
const previewNodeTypes: NodeTypes = {
|
||||
workflowBlock: PreviewBlock,
|
||||
noteBlock: PreviewBlock,
|
||||
subflowNode: PreviewSubflow,
|
||||
}
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
default: WorkflowEdge,
|
||||
workflowEdge: WorkflowEdge,
|
||||
}
|
||||
|
||||
interface FitViewOnChangeProps {
|
||||
nodeIds: string
|
||||
fitPadding: number
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
|
||||
* Only triggers on actual node additions/removals, not on selection changes.
|
||||
* Must be rendered inside ReactFlowProvider.
|
||||
*/
|
||||
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
|
||||
const { fitView } = useReactFlow()
|
||||
const lastNodeIdsRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeIds.length) return
|
||||
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
||||
if (!shouldFit) return
|
||||
lastNodeIdsRef.current = nodeIds
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 200 })
|
||||
}, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [nodeIds, fitPadding, fitView])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 150 })
|
||||
}, 100)
|
||||
})
|
||||
|
||||
resizeObserver.observe(container)
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [containerRef, fitPadding, fitView])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Readonly workflow component for visualizing workflow state.
|
||||
* Renders blocks, subflows, and edges with execution status highlighting.
|
||||
*
|
||||
* @remarks
|
||||
* - Supports panning and node click interactions
|
||||
* - Shows execution path via green edges for successful paths
|
||||
* - Error edges display red by default, green when error path was taken
|
||||
* - Fits view automatically when nodes change or container resizes
|
||||
*/
|
||||
export function PreviewWorkflow({
|
||||
workflowState,
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
isPannable = true,
|
||||
defaultPosition,
|
||||
defaultZoom = 0.8,
|
||||
fitPadding = 0.25,
|
||||
onNodeClick,
|
||||
onNodeContextMenu,
|
||||
onPaneClick,
|
||||
cursorStyle = 'grab',
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
lightweight = false,
|
||||
}: PreviewWorkflowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nodeTypes = previewNodeTypes
|
||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||
|
||||
const blocksStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.blocks || {}).length,
|
||||
ids: Object.keys(workflowState.blocks || {}).join(','),
|
||||
}
|
||||
}, [workflowState.blocks, isValidWorkflowState])
|
||||
|
||||
const loopsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.loops || {}).length,
|
||||
ids: Object.keys(workflowState.loops || {}).join(','),
|
||||
}
|
||||
}, [workflowState.loops, isValidWorkflowState])
|
||||
|
||||
const parallelsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.parallels || {}).length,
|
||||
ids: Object.keys(workflowState.parallels || {}).join(','),
|
||||
}
|
||||
}, [workflowState.parallels, isValidWorkflowState])
|
||||
|
||||
/** Map of subflow ID to child block IDs */
|
||||
const subflowChildrenMap = useMemo(() => {
|
||||
if (!isValidWorkflowState) return new Map<string, string[]>()
|
||||
|
||||
const map = new Map<string, string[]>()
|
||||
for (const [blockId, block] of Object.entries(workflowState.blocks || {})) {
|
||||
const parentId = block?.data?.parentId
|
||||
if (parentId) {
|
||||
const children = map.get(parentId) || []
|
||||
children.push(blockId)
|
||||
map.set(parentId, children)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [workflowState.blocks, isValidWorkflowState])
|
||||
|
||||
/** Derives subflow execution status from child blocks */
|
||||
const getSubflowExecutionStatus = useMemo(() => {
|
||||
return (subflowId: string): ExecutionStatus | undefined => {
|
||||
if (!executedBlocks) return undefined
|
||||
|
||||
const childIds = subflowChildrenMap.get(subflowId)
|
||||
if (!childIds?.length) return undefined
|
||||
|
||||
const childStatuses = childIds.map((id) => executedBlocks[id]).filter(Boolean)
|
||||
if (childStatuses.length === 0) return undefined
|
||||
|
||||
if (childStatuses.some((s) => s.status === 'error')) return 'error'
|
||||
if (childStatuses.some((s) => s.status === 'success')) return 'success'
|
||||
return 'not-executed'
|
||||
}
|
||||
}, [executedBlocks, subflowChildrenMap])
|
||||
|
||||
/** Gets execution status for any block, deriving subflow status from children */
|
||||
const getBlockExecutionStatus = useMemo(() => {
|
||||
return (blockId: string): { status: string; executed: boolean } | undefined => {
|
||||
if (!executedBlocks) return undefined
|
||||
|
||||
const directStatus = executedBlocks[blockId]
|
||||
if (directStatus) {
|
||||
return { status: directStatus.status, executed: true }
|
||||
}
|
||||
|
||||
const block = workflowState.blocks?.[blockId]
|
||||
if (block && (block.type === 'loop' || block.type === 'parallel')) {
|
||||
const subflowStatus = getSubflowExecutionStatus(blockId)
|
||||
if (subflowStatus) {
|
||||
return { status: subflowStatus, executed: true }
|
||||
}
|
||||
|
||||
const incomingEdge = workflowState.edges?.find((e) => e.target === blockId)
|
||||
if (incomingEdge && executedBlocks[incomingEdge.source]?.status === 'success') {
|
||||
return { status: 'not-executed', executed: true }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}, [executedBlocks, workflowState.blocks, workflowState.edges, getSubflowExecutionStatus])
|
||||
|
||||
const edgesStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: workflowState.edges?.length || 0,
|
||||
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
|
||||
}
|
||||
}, [workflowState.edges, isValidWorkflowState])
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
const nodeArray: Node[] = []
|
||||
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
if (!block || !block.type) {
|
||||
logger.warn(`Skipping invalid block: ${blockId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
||||
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
const subflowExecutionStatus = getSubflowExecutionStatus(blockId)
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
data: {
|
||||
name: block.name,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
kind: block.type as 'loop' | 'parallel',
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus: subflowExecutionStatus,
|
||||
lightweight,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedBlockId === blockId
|
||||
|
||||
let executionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
const blockExecution = executedBlocks[blockId]
|
||||
if (blockExecution) {
|
||||
if (blockExecution.status === 'error') {
|
||||
executionStatus = 'error'
|
||||
} else if (blockExecution.status === 'success') {
|
||||
executionStatus = 'success'
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
}
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'workflowBlock',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
zIndex: block.data?.parentId ? 10 : undefined,
|
||||
data: {
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
isTrigger: block.triggerMode === true,
|
||||
horizontalHandles: block.horizontalHandles ?? false,
|
||||
enabled: block.enabled ?? true,
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus,
|
||||
subBlockValues: block.subBlocks,
|
||||
lightweight,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [
|
||||
blocksStructure,
|
||||
loopsStructure,
|
||||
parallelsStructure,
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
getSubflowExecutionStatus,
|
||||
lightweight,
|
||||
])
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
/**
|
||||
* Determines edge execution status for visualization.
|
||||
* Error edges turn green when taken (source errored, target executed).
|
||||
* Normal edges turn green when both source succeeded and target executed.
|
||||
*/
|
||||
const getEdgeExecutionStatus = (edge: {
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string | null
|
||||
}): ExecutionStatus | undefined => {
|
||||
if (!executedBlocks) return undefined
|
||||
|
||||
const sourceStatus = getBlockExecutionStatus(edge.source)
|
||||
const targetStatus = getBlockExecutionStatus(edge.target)
|
||||
const isErrorEdge = edge.sourceHandle === 'error'
|
||||
|
||||
if (isErrorEdge) {
|
||||
return sourceStatus?.status === 'error' && targetStatus?.executed
|
||||
? 'success'
|
||||
: 'not-executed'
|
||||
}
|
||||
|
||||
const isSubflowStartEdge =
|
||||
edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source'
|
||||
|
||||
if (isSubflowStartEdge) {
|
||||
const incomingEdge = workflowState.edges?.find((e) => e.target === edge.source)
|
||||
const incomingSucceeded = incomingEdge
|
||||
? executedBlocks[incomingEdge.source]?.status === 'success'
|
||||
: false
|
||||
return incomingSucceeded ? 'success' : 'not-executed'
|
||||
}
|
||||
|
||||
const targetBlock = workflowState.blocks?.[edge.target]
|
||||
const targetIsSubflow =
|
||||
targetBlock && (targetBlock.type === 'loop' || targetBlock.type === 'parallel')
|
||||
|
||||
if (sourceStatus?.status === 'success' && (targetStatus?.executed || targetIsSubflow)) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
return 'not-executed'
|
||||
}
|
||||
|
||||
return (workflowState.edges || []).map((edge) => {
|
||||
const status = getEdgeExecutionStatus(edge)
|
||||
const isErrorEdge = edge.sourceHandle === 'error'
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: {
|
||||
...(status ? { executionStatus: status } : {}),
|
||||
sourceHandle: edge.sourceHandle,
|
||||
},
|
||||
zIndex: status === 'success' ? 10 : isErrorEdge ? 5 : 0,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
edgesStructure,
|
||||
workflowState.edges,
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
executedBlocks,
|
||||
getBlockExecutionStatus,
|
||||
])
|
||||
|
||||
if (!isValidWorkflowState) {
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
|
||||
>
|
||||
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||
<div className='mb-2 font-medium text-lg'>⚠️ Logged State Not Found</div>
|
||||
<div className='text-sm'>
|
||||
This log was migrated from the old system and doesn't contain workflow state data.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
||||
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
|
||||
>
|
||||
<style>{`
|
||||
/* Canvas cursor - grab on the flow container and pane */
|
||||
.preview-mode .react-flow { cursor: ${cursorStyle}; }
|
||||
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
|
||||
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
|
||||
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
|
||||
|
||||
/* Active/grabbing cursor when dragging */
|
||||
${
|
||||
cursorStyle === 'grab'
|
||||
? `
|
||||
.preview-mode .react-flow:active { cursor: grabbing; }
|
||||
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
|
||||
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
/* Node cursor - pointer on nodes when onNodeClick is provided */
|
||||
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
|
||||
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
||||
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
|
||||
`}</style>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
fitView
|
||||
fitViewOptions={{ padding: fitPadding }}
|
||||
panOnScroll={isPannable}
|
||||
panOnDrag={isPannable}
|
||||
zoomOnScroll={false}
|
||||
draggable={false}
|
||||
defaultViewport={{
|
||||
x: defaultPosition?.x ?? 0,
|
||||
y: defaultPosition?.y ?? 0,
|
||||
zoom: defaultZoom ?? 1,
|
||||
}}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
onNodeClick={
|
||||
onNodeClick
|
||||
? (event, node) => {
|
||||
logger.debug('Node clicked:', { nodeId: node.id, event })
|
||||
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNodeContextMenu={
|
||||
onNodeContextMenu
|
||||
? (event, node) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPaneClick={onPaneClick}
|
||||
/>
|
||||
<FitViewOnChange
|
||||
nodeIds={blocksStructure.ids}
|
||||
fitPadding={fitPadding}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
export { PreviewContextMenu } from './components/preview-context-menu'
|
||||
export { PreviewEditor } from './components/preview-editor'
|
||||
export { getLeftmostBlockId, WorkflowPreview } from './preview'
|
||||
export { getLeftmostBlockId, PreviewWorkflow } from './components/preview-workflow'
|
||||
export { PreviewBlock } from './components/preview-workflow/components/block'
|
||||
export { PreviewSubflow } from './components/preview-workflow/components/subflow'
|
||||
export { buildBlockExecutions, Preview } from './preview'
|
||||
|
||||
@@ -1,499 +1,292 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import ReactFlow, {
|
||||
ConnectionLineType,
|
||||
type Edge,
|
||||
type EdgeTypes,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { PreviewEditor } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-editor'
|
||||
import {
|
||||
getLeftmostBlockId,
|
||||
PreviewWorkflow,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowPreview')
|
||||
|
||||
/**
|
||||
* Gets block dimensions for preview purposes.
|
||||
* For containers, uses stored dimensions or defaults.
|
||||
* For regular blocks, uses stored height or estimates based on type.
|
||||
*/
|
||||
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
return {
|
||||
width: block.data?.width
|
||||
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
|
||||
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: block.data?.height
|
||||
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
|
||||
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.height) {
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
return estimateBlockDimensions(block.type)
|
||||
interface TraceSpan {
|
||||
blockId?: string
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
status?: string
|
||||
duration?: number
|
||||
children?: TraceSpan[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions and sizes.
|
||||
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
|
||||
*/
|
||||
function calculateContainerDimensions(
|
||||
containerId: string,
|
||||
blocks: Record<string, BlockState>
|
||||
): { width: number; height: number } {
|
||||
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
|
||||
|
||||
if (childBlocks.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
for (const child of childBlocks) {
|
||||
if (!child?.position) continue
|
||||
|
||||
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
|
||||
|
||||
maxRight = Math.max(maxRight, child.position.x + childWidth)
|
||||
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
interface BlockExecutionData {
|
||||
input: unknown
|
||||
output: unknown
|
||||
status: string
|
||||
durationMs: number
|
||||
/** Child trace spans for nested workflow blocks */
|
||||
children?: TraceSpan[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the leftmost block ID from a workflow state.
|
||||
* Returns the block with the smallest x position, excluding subflow containers (loop/parallel).
|
||||
*/
|
||||
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
|
||||
if (!workflowState?.blocks) return null
|
||||
|
||||
let leftmostId: string | null = null
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
|
||||
if (!block || block.type === 'loop' || block.type === 'parallel') continue
|
||||
const x = block.position?.x ?? Number.POSITIVE_INFINITY
|
||||
if (x < minX) {
|
||||
minX = x
|
||||
leftmostId = blockId
|
||||
}
|
||||
}
|
||||
|
||||
return leftmostId
|
||||
}
|
||||
|
||||
/** Execution status for edges/nodes in the preview */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
interface WorkflowPreviewProps {
|
||||
/** Represents a level in the workflow navigation stack */
|
||||
interface WorkflowStackEntry {
|
||||
workflowState: WorkflowState
|
||||
className?: string
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
isPannable?: boolean
|
||||
defaultPosition?: { x: number; y: number }
|
||||
defaultZoom?: number
|
||||
fitPadding?: number
|
||||
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Callback when a node is right-clicked */
|
||||
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Callback when the canvas (empty area) is clicked */
|
||||
onPaneClick?: () => void
|
||||
/** Cursor style to show when hovering the canvas */
|
||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
||||
executedBlocks?: Record<string, { status: string }>
|
||||
/** Currently selected block ID for highlighting */
|
||||
selectedBlockId?: string | null
|
||||
traceSpans: TraceSpan[]
|
||||
blockExecutions: Record<string, BlockExecutionData>
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview node types using minimal components without hooks or store subscriptions.
|
||||
* This prevents interaction issues while allowing canvas panning and node clicking.
|
||||
* Extracts child trace spans from a workflow block's execution data.
|
||||
* Checks both the `children` property (where trace span processing moves them)
|
||||
* and the legacy `output.childTraceSpans` for compatibility.
|
||||
*/
|
||||
const previewNodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowPreviewBlock,
|
||||
noteBlock: WorkflowPreviewBlock,
|
||||
subflowNode: WorkflowPreviewSubflow,
|
||||
}
|
||||
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
|
||||
if (!blockExecution) return []
|
||||
|
||||
// Define edge types
|
||||
const edgeTypes: EdgeTypes = {
|
||||
default: WorkflowEdge,
|
||||
workflowEdge: WorkflowEdge, // Keep for backward compatibility
|
||||
}
|
||||
if (Array.isArray(blockExecution.children) && blockExecution.children.length > 0) {
|
||||
return blockExecution.children
|
||||
}
|
||||
|
||||
interface FitViewOnChangeProps {
|
||||
nodeIds: string
|
||||
fitPadding: number
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
|
||||
* Only triggers on actual node additions/removals, not on selection changes.
|
||||
* Must be rendered inside ReactFlowProvider.
|
||||
*/
|
||||
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
|
||||
const { fitView } = useReactFlow()
|
||||
const lastNodeIdsRef = useRef<string | null>(null)
|
||||
|
||||
// Fit view when nodes change
|
||||
useEffect(() => {
|
||||
if (!nodeIds.length) return
|
||||
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
||||
if (!shouldFit) return
|
||||
lastNodeIdsRef.current = nodeIds
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 200 })
|
||||
}, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [nodeIds, fitPadding, fitView])
|
||||
|
||||
// Fit view when container resizes (debounced to avoid excessive calls during drag)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 150 })
|
||||
}, 100)
|
||||
})
|
||||
|
||||
resizeObserver.observe(container)
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
resizeObserver.disconnect()
|
||||
if (blockExecution.output && typeof blockExecution.output === 'object') {
|
||||
const output = blockExecution.output as Record<string, unknown>
|
||||
if (Array.isArray(output.childTraceSpans)) {
|
||||
return output.childTraceSpans as TraceSpan[]
|
||||
}
|
||||
}, [containerRef, fitPadding, fitView])
|
||||
}
|
||||
|
||||
return null
|
||||
return []
|
||||
}
|
||||
|
||||
export function WorkflowPreview({
|
||||
workflowState,
|
||||
/**
|
||||
* Builds block execution data from trace spans
|
||||
*/
|
||||
export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockExecutionData> {
|
||||
const blockExecutionMap: Record<string, BlockExecutionData> = {}
|
||||
|
||||
const collectBlockSpans = (traceSpans: TraceSpan[]): TraceSpan[] => {
|
||||
const blockSpans: TraceSpan[] = []
|
||||
for (const span of traceSpans) {
|
||||
if (span.blockId) {
|
||||
blockSpans.push(span)
|
||||
}
|
||||
if (span.children && Array.isArray(span.children)) {
|
||||
blockSpans.push(...collectBlockSpans(span.children))
|
||||
}
|
||||
}
|
||||
return blockSpans
|
||||
}
|
||||
|
||||
const allBlockSpans = collectBlockSpans(spans)
|
||||
|
||||
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,
|
||||
children: span.children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blockExecutionMap
|
||||
}
|
||||
|
||||
interface PreviewProps {
|
||||
/** The workflow state to display */
|
||||
workflowState: WorkflowState
|
||||
/** Trace spans for the execution (optional - enables execution mode features) */
|
||||
traceSpans?: TraceSpan[]
|
||||
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
|
||||
blockExecutions?: Record<string, BlockExecutionData>
|
||||
/** Additional CSS class names */
|
||||
className?: string
|
||||
/** Height of the component */
|
||||
height?: string | number
|
||||
/** Width of the component */
|
||||
width?: string | number
|
||||
/** Callback when canvas context menu is opened */
|
||||
onCanvasContextMenu?: (e: React.MouseEvent) => void
|
||||
/** Callback when a node context menu is opened */
|
||||
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Whether to show border around the component */
|
||||
showBorder?: boolean
|
||||
/** Initial block to select (defaults to leftmost block) */
|
||||
initialSelectedBlockId?: string | null
|
||||
/** Whether to auto-select the leftmost block on mount */
|
||||
autoSelectLeftmost?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Main preview component that combines PreviewCanvas with PreviewEditor
|
||||
* and handles nested workflow navigation via a stack.
|
||||
*
|
||||
* @remarks
|
||||
* - Manages navigation stack for drilling into nested workflow blocks
|
||||
* - Displays back button when viewing nested workflows
|
||||
* - Properly passes execution data through to nested levels
|
||||
* - Can be used anywhere a workflow preview with editor is needed
|
||||
*/
|
||||
export function Preview({
|
||||
workflowState: rootWorkflowState,
|
||||
traceSpans: rootTraceSpans,
|
||||
blockExecutions: providedBlockExecutions,
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
isPannable = true,
|
||||
defaultPosition,
|
||||
defaultZoom = 0.8,
|
||||
fitPadding = 0.25,
|
||||
onNodeClick,
|
||||
onCanvasContextMenu,
|
||||
onNodeContextMenu,
|
||||
onPaneClick,
|
||||
cursorStyle = 'grab',
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
}: WorkflowPreviewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nodeTypes = previewNodeTypes
|
||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||
|
||||
const blocksStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.blocks || {}).length,
|
||||
ids: Object.keys(workflowState.blocks || {}).join(','),
|
||||
showBorder = false,
|
||||
initialSelectedBlockId,
|
||||
autoSelectLeftmost = true,
|
||||
}: PreviewProps) {
|
||||
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
|
||||
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
|
||||
if (initialSelectedBlockId) return initialSelectedBlockId
|
||||
if (autoSelectLeftmost) {
|
||||
return getLeftmostBlockId(rootWorkflowState)
|
||||
}
|
||||
}, [workflowState.blocks, isValidWorkflowState])
|
||||
return null
|
||||
})
|
||||
|
||||
const loopsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.loops || {}).length,
|
||||
ids: Object.keys(workflowState.loops || {}).join(','),
|
||||
/** Stack for nested workflow navigation. Empty means we're at the root level. */
|
||||
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
|
||||
|
||||
/** Block executions for the root level */
|
||||
const rootBlockExecutions = useMemo(() => {
|
||||
if (providedBlockExecutions) return providedBlockExecutions
|
||||
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
|
||||
return buildBlockExecutions(rootTraceSpans)
|
||||
}, [providedBlockExecutions, rootTraceSpans])
|
||||
|
||||
/** Current block executions - either from stack or root */
|
||||
const blockExecutions = useMemo(() => {
|
||||
if (workflowStack.length > 0) {
|
||||
return workflowStack[workflowStack.length - 1].blockExecutions
|
||||
}
|
||||
}, [workflowState.loops, isValidWorkflowState])
|
||||
return rootBlockExecutions
|
||||
}, [workflowStack, rootBlockExecutions])
|
||||
|
||||
const parallelsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.parallels || {}).length,
|
||||
ids: Object.keys(workflowState.parallels || {}).join(','),
|
||||
/** Current workflow state - either from stack or root */
|
||||
const workflowState = useMemo(() => {
|
||||
if (workflowStack.length > 0) {
|
||||
return workflowStack[workflowStack.length - 1].workflowState
|
||||
}
|
||||
}, [workflowState.parallels, isValidWorkflowState])
|
||||
return rootWorkflowState
|
||||
}, [workflowStack, rootWorkflowState])
|
||||
|
||||
const edgesStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: workflowState.edges?.length || 0,
|
||||
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
|
||||
}
|
||||
}, [workflowState.edges, isValidWorkflowState])
|
||||
/** Whether we're in execution mode (have trace spans/block executions) */
|
||||
const isExecutionMode = useMemo(() => {
|
||||
return Object.keys(blockExecutions).length > 0
|
||||
}, [blockExecutions])
|
||||
|
||||
const calculateAbsolutePosition = (
|
||||
block: any,
|
||||
blocks: Record<string, any>
|
||||
): { x: number; y: number } => {
|
||||
if (!block.data?.parentId) {
|
||||
return block.position
|
||||
}
|
||||
/** Handler to drill down into a nested workflow block */
|
||||
const handleDrillDown = useCallback(
|
||||
(blockId: string, childWorkflowState: WorkflowState) => {
|
||||
const blockExecution = blockExecutions[blockId]
|
||||
const childTraceSpans = extractChildTraceSpans(blockExecution)
|
||||
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
|
||||
|
||||
const parentBlock = blocks[block.data.parentId]
|
||||
if (!parentBlock) {
|
||||
logger.warn(`Parent block not found for child block: ${block.id}`)
|
||||
return block.position
|
||||
}
|
||||
|
||||
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
|
||||
|
||||
return {
|
||||
x: parentAbsolutePosition.x + block.position.x,
|
||||
y: parentAbsolutePosition.y + block.position.y,
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
const nodeArray: Node[] = []
|
||||
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
if (!block || !block.type) {
|
||||
logger.warn(`Skipping invalid block: ${blockId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
||||
|
||||
// Handle loop/parallel containers
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
data: {
|
||||
name: block.name,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
kind: block.type as 'loop' | 'parallel',
|
||||
isPreviewSelected: isSelected,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle regular blocks
|
||||
const isSelected = selectedBlockId === blockId
|
||||
|
||||
let executionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
const blockExecution = executedBlocks[blockId]
|
||||
if (blockExecution) {
|
||||
if (blockExecution.status === 'error') {
|
||||
executionStatus = 'error'
|
||||
} else if (blockExecution.status === 'success') {
|
||||
executionStatus = 'success'
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
}
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'workflowBlock',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
// Blocks inside subflows need higher z-index to appear above the container
|
||||
zIndex: block.data?.parentId ? 10 : undefined,
|
||||
data: {
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
isTrigger: block.triggerMode === true,
|
||||
horizontalHandles: block.horizontalHandles ?? false,
|
||||
enabled: block.enabled ?? true,
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus,
|
||||
subBlockValues: block.subBlocks,
|
||||
setWorkflowStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
workflowState: childWorkflowState,
|
||||
traceSpans: childTraceSpans,
|
||||
blockExecutions: childBlockExecutions,
|
||||
},
|
||||
})
|
||||
})
|
||||
])
|
||||
|
||||
return nodeArray
|
||||
}, [
|
||||
blocksStructure,
|
||||
loopsStructure,
|
||||
parallelsStructure,
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
])
|
||||
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
|
||||
const leftmostId = getLeftmostBlockId(childWorkflowState)
|
||||
setPinnedBlockId(leftmostId)
|
||||
},
|
||||
[blockExecutions]
|
||||
)
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
/** Handler to go back up the stack */
|
||||
const handleGoBack = useCallback(() => {
|
||||
setWorkflowStack((prev) => prev.slice(0, -1))
|
||||
setPinnedBlockId(null)
|
||||
}, [])
|
||||
|
||||
return (workflowState.edges || []).map((edge) => {
|
||||
let executionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
const sourceExecuted = executedBlocks[edge.source]
|
||||
const targetExecuted = executedBlocks[edge.target]
|
||||
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
|
||||
const handleNodeClick = useCallback((blockId: string) => {
|
||||
setPinnedBlockId(blockId)
|
||||
}, [])
|
||||
|
||||
if (sourceExecuted && targetExecuted) {
|
||||
// Edge is success if source succeeded and target was executed (even if target errored)
|
||||
if (sourceExecuted.status === 'success') {
|
||||
executionStatus = 'success'
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
}
|
||||
}
|
||||
const handlePaneClick = useCallback(() => {
|
||||
setPinnedBlockId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: executionStatus ? { executionStatus } : undefined,
|
||||
// Raise executed edges above default edges
|
||||
zIndex: executionStatus === 'success' ? 10 : 0,
|
||||
}
|
||||
})
|
||||
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
|
||||
const handleEditorClose = useCallback(() => {
|
||||
setPinnedBlockId(null)
|
||||
}, [])
|
||||
|
||||
if (!isValidWorkflowState) {
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
|
||||
>
|
||||
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||
<div className='mb-2 font-medium text-lg'>⚠️ Logged State Not Found</div>
|
||||
<div className='text-sm'>
|
||||
This log was migrated from the old system and doesn't contain workflow state data.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
setWorkflowStack([])
|
||||
}, [rootWorkflowState])
|
||||
|
||||
const isNested = workflowStack.length > 0
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
||||
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
|
||||
>
|
||||
<style>{`
|
||||
/* Canvas cursor - grab on the flow container and pane */
|
||||
.preview-mode .react-flow { cursor: ${cursorStyle}; }
|
||||
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
|
||||
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
|
||||
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className={cn(
|
||||
'relative flex overflow-hidden',
|
||||
showBorder && 'rounded-[4px] border border-[var(--border)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isNested && (
|
||||
<div className='absolute top-[12px] left-[12px] z-20'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleGoBack}
|
||||
className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]'
|
||||
>
|
||||
<ArrowLeft className='h-[13px] w-[13px]' />
|
||||
<span className='font-medium text-[13px]'>Back</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
/* Active/grabbing cursor when dragging */
|
||||
${
|
||||
cursorStyle === 'grab'
|
||||
? `
|
||||
.preview-mode .react-flow:active { cursor: grabbing; }
|
||||
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
|
||||
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
/* Node cursor - pointer on nodes when onNodeClick is provided */
|
||||
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
|
||||
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
||||
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
|
||||
`}</style>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
fitView
|
||||
fitViewOptions={{ padding: fitPadding }}
|
||||
panOnScroll={isPannable}
|
||||
panOnDrag={isPannable}
|
||||
zoomOnScroll={false}
|
||||
draggable={false}
|
||||
defaultViewport={{
|
||||
x: defaultPosition?.x ?? 0,
|
||||
y: defaultPosition?.y ?? 0,
|
||||
zoom: defaultZoom ?? 1,
|
||||
}}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
onNodeClick={
|
||||
onNodeClick
|
||||
? (event, node) => {
|
||||
logger.debug('Node clicked:', { nodeId: node.id, event })
|
||||
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNodeContextMenu={
|
||||
onNodeContextMenu
|
||||
? (event, node) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPaneClick={onPaneClick}
|
||||
/>
|
||||
<FitViewOnChange
|
||||
nodeIds={blocksStructure.ids}
|
||||
fitPadding={fitPadding}
|
||||
containerRef={containerRef}
|
||||
<div className='h-full flex-1' onContextMenu={onCanvasContextMenu}>
|
||||
<PreviewWorkflow
|
||||
workflowState={workflowState}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onPaneClick={handlePaneClick}
|
||||
cursorStyle='pointer'
|
||||
executedBlocks={blockExecutions}
|
||||
selectedBlockId={pinnedBlockId}
|
||||
/>
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
|
||||
{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={isExecutionMode}
|
||||
onClose={handleEditorClose}
|
||||
onDrillDown={handleDrillDown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,6 +185,16 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
return `claude mcp add "${safeName}" --url "${mcpServerUrl}" --header "X-API-Key:$SIM_API_KEY"`
|
||||
}
|
||||
|
||||
// Cursor supports direct URL configuration (no mcp-remote needed)
|
||||
if (client === 'cursor') {
|
||||
const cursorConfig = isPublic
|
||||
? { url: mcpServerUrl }
|
||||
: { url: mcpServerUrl, headers: { 'X-API-Key': '$SIM_API_KEY' } }
|
||||
|
||||
return JSON.stringify({ mcpServers: { [safeName]: cursorConfig } }, null, 2)
|
||||
}
|
||||
|
||||
// Claude Desktop and VS Code still use mcp-remote (stdio transport)
|
||||
const mcpRemoteArgs = isPublic
|
||||
? ['-y', 'mcp-remote', mcpServerUrl]
|
||||
: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY']
|
||||
@@ -265,14 +275,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
|
||||
const config = isPublic
|
||||
? {
|
||||
command: 'npx',
|
||||
args: ['-y', 'mcp-remote', mcpServerUrl],
|
||||
}
|
||||
: {
|
||||
command: 'npx',
|
||||
args: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'],
|
||||
}
|
||||
? { url: mcpServerUrl }
|
||||
: { url: mcpServerUrl, headers: { 'X-API-Key': '$SIM_API_KEY' } }
|
||||
|
||||
const base64Config = btoa(JSON.stringify(config))
|
||||
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(safeName)}&config=${encodeURIComponent(base64Config)}`
|
||||
|
||||
@@ -158,7 +158,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
|
||||
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
|
||||
{
|
||||
id: 'byok',
|
||||
label: 'BYOK',
|
||||
|
||||
@@ -513,6 +513,12 @@ Return ONLY the JSON array.`,
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'maxTokens',
|
||||
title: 'Max Output Tokens',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter max tokens (e.g., 4096)...',
|
||||
},
|
||||
{
|
||||
id: 'responseFormat',
|
||||
title: 'Response Format',
|
||||
@@ -754,6 +760,7 @@ Example 3 (Array Input):
|
||||
},
|
||||
},
|
||||
temperature: { type: 'number', description: 'Response randomness level' },
|
||||
maxTokens: { type: 'number', description: 'Maximum number of tokens in the response' },
|
||||
reasoningEffort: { type: 'string', description: 'Reasoning effort level for GPT-5 models' },
|
||||
verbosity: { type: 'string', description: 'Verbosity level for GPT-5 models' },
|
||||
thinkingLevel: { type: 'string', description: 'Thinking level for Gemini 3 models' },
|
||||
|
||||
@@ -36,7 +36,22 @@ export const IntercomBlock: BlockConfig = {
|
||||
{ label: 'Search Conversations', id: 'search_conversations' },
|
||||
{ label: 'Create Ticket', id: 'create_ticket' },
|
||||
{ label: 'Get Ticket', id: 'get_ticket' },
|
||||
{ label: 'Update Ticket', id: 'update_ticket' },
|
||||
{ label: 'Create Message', id: 'create_message' },
|
||||
{ label: 'List Admins', id: 'list_admins' },
|
||||
{ label: 'Close Conversation', id: 'close_conversation' },
|
||||
{ label: 'Open Conversation', id: 'open_conversation' },
|
||||
{ label: 'Snooze Conversation', id: 'snooze_conversation' },
|
||||
{ label: 'Assign Conversation', id: 'assign_conversation' },
|
||||
{ label: 'List Tags', id: 'list_tags' },
|
||||
{ label: 'Create Tag', id: 'create_tag' },
|
||||
{ label: 'Tag Contact', id: 'tag_contact' },
|
||||
{ label: 'Untag Contact', id: 'untag_contact' },
|
||||
{ label: 'Tag Conversation', id: 'tag_conversation' },
|
||||
{ label: 'Create Note', id: 'create_note' },
|
||||
{ label: 'Create Event', id: 'create_event' },
|
||||
{ label: 'Attach Contact to Company', id: 'attach_contact_to_company' },
|
||||
{ label: 'Detach Contact from Company', id: 'detach_contact_from_company' },
|
||||
],
|
||||
value: () => 'create_contact',
|
||||
},
|
||||
@@ -384,7 +399,15 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_conversation', 'reply_conversation'],
|
||||
value: [
|
||||
'get_conversation',
|
||||
'reply_conversation',
|
||||
'close_conversation',
|
||||
'open_conversation',
|
||||
'snooze_conversation',
|
||||
'assign_conversation',
|
||||
'tag_conversation',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -477,11 +500,20 @@ Return ONLY the message text - no explanations.`,
|
||||
id: 'admin_id',
|
||||
title: 'Admin ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'ID of the admin sending the message',
|
||||
placeholder: 'ID of the admin performing the action',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['reply_conversation'],
|
||||
value: [
|
||||
'reply_conversation',
|
||||
'close_conversation',
|
||||
'open_conversation',
|
||||
'snooze_conversation',
|
||||
'assign_conversation',
|
||||
'tag_conversation',
|
||||
'create_note',
|
||||
'update_ticket',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -526,7 +558,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_ticket'],
|
||||
value: ['get_ticket', 'update_ticket'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -799,6 +831,307 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
value: ['list_companies'],
|
||||
},
|
||||
},
|
||||
// Close/Open conversation body
|
||||
{
|
||||
id: 'close_body',
|
||||
title: 'Closing Message',
|
||||
type: 'long-input',
|
||||
placeholder: 'Optional message to add when closing',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['close_conversation'],
|
||||
},
|
||||
},
|
||||
// Snooze conversation
|
||||
{
|
||||
id: 'snoozed_until',
|
||||
title: 'Snooze Until',
|
||||
type: 'short-input',
|
||||
placeholder: 'Unix timestamp when conversation should reopen',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['snooze_conversation'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a Unix timestamp in seconds based on the user's description.
|
||||
The timestamp should be a Unix epoch time in seconds (10 digits).
|
||||
Examples:
|
||||
- "tomorrow" -> Tomorrow at 09:00:00 as Unix timestamp
|
||||
- "in 2 hours" -> Current time plus 7200 seconds
|
||||
- "next Monday" -> Next Monday at 09:00:00 as Unix timestamp
|
||||
|
||||
Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Describe when to unsnooze (e.g., "tomorrow", "in 2 hours")...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
// Assign conversation
|
||||
{
|
||||
id: 'assignee_id',
|
||||
title: 'Assignee ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Admin or team ID to assign to (0 to unassign)',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['assign_conversation'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'assign_body',
|
||||
title: 'Assignment Message',
|
||||
type: 'long-input',
|
||||
placeholder: 'Optional message when assigning',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['assign_conversation'],
|
||||
},
|
||||
},
|
||||
// Update ticket fields
|
||||
{
|
||||
id: 'update_ticket_attributes',
|
||||
title: 'Ticket Attributes',
|
||||
type: 'long-input',
|
||||
placeholder: 'JSON object with ticket attributes to update',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['update_ticket'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a JSON object for Intercom ticket attributes based on the user's description.
|
||||
Example: {"_default_title_": "Updated title", "_default_description_": "Updated description"}
|
||||
|
||||
Return ONLY the JSON object - no explanations or markdown formatting.`,
|
||||
placeholder: 'Describe the ticket updates (e.g., "change title to Bug Fixed")...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ticket_open',
|
||||
title: 'Ticket Open',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Keep Open', id: 'true' },
|
||||
{ label: 'Close Ticket', id: 'false' },
|
||||
],
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['update_ticket'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ticket_is_shared',
|
||||
title: 'Ticket Visible to Users',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['update_ticket'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ticket_snoozed_until',
|
||||
title: 'Snooze Ticket Until',
|
||||
type: 'short-input',
|
||||
placeholder: 'Unix timestamp when ticket should reopen',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['update_ticket'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a Unix timestamp in seconds based on the user's description.
|
||||
Examples:
|
||||
- "tomorrow" -> Tomorrow at 09:00:00 as Unix timestamp
|
||||
- "next week" -> 7 days from now
|
||||
|
||||
Return ONLY the numeric timestamp.`,
|
||||
placeholder: 'Describe when to unsnooze (e.g., "tomorrow")...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ticket_assignee_id',
|
||||
title: 'Ticket Assignee ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Admin or team ID to assign to (0 to unassign)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['update_ticket'],
|
||||
},
|
||||
},
|
||||
// Tag fields
|
||||
{
|
||||
id: 'tagId',
|
||||
title: 'Tag ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'ID of the tag',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['tag_contact', 'untag_contact', 'tag_conversation'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tag_name',
|
||||
title: 'Tag Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Name of the tag to create',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_tag'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tag_id_update',
|
||||
title: 'Tag ID (for update)',
|
||||
type: 'short-input',
|
||||
placeholder: 'ID of existing tag to update (leave empty to create new)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_tag'],
|
||||
},
|
||||
},
|
||||
// Contact ID for tag/untag/note operations
|
||||
{
|
||||
id: 'tag_contact_id',
|
||||
title: 'Contact ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'ID of the contact',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'tag_contact',
|
||||
'untag_contact',
|
||||
'create_note',
|
||||
'attach_contact_to_company',
|
||||
'detach_contact_from_company',
|
||||
],
|
||||
},
|
||||
},
|
||||
// Note fields
|
||||
{
|
||||
id: 'note_body',
|
||||
title: 'Note Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Text content of the note',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_note'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a note for Intercom based on the user's description.
|
||||
The note should be clear, professional, and capture the key information.
|
||||
|
||||
Return ONLY the note text - no explanations.`,
|
||||
placeholder: 'Describe the note content (e.g., "customer requested callback")...',
|
||||
},
|
||||
},
|
||||
// Event fields
|
||||
{
|
||||
id: 'event_name',
|
||||
title: 'Event Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Event name (e.g., order-completed)',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_event'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event_user_id',
|
||||
title: 'User ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Your identifier for the user',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_event'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event_email',
|
||||
title: 'User Email',
|
||||
type: 'short-input',
|
||||
placeholder: 'Email address of the user',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_event'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event_contact_id',
|
||||
title: 'Contact ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Intercom contact ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_event'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event_metadata',
|
||||
title: 'Event Metadata',
|
||||
type: 'long-input',
|
||||
placeholder: 'JSON object with event metadata (max 10 keys)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_event'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a JSON object for Intercom event metadata based on the user's description.
|
||||
The object should contain key-value pairs (max 10 keys).
|
||||
Example: {"order_value": 99.99, "items": 3, "coupon_used": true}
|
||||
|
||||
Return ONLY the JSON object - no explanations or markdown formatting.`,
|
||||
placeholder: 'Describe the event data (e.g., "order value $50, 2 items")...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event_created_at',
|
||||
title: 'Event Time',
|
||||
type: 'short-input',
|
||||
placeholder: 'Unix timestamp when event occurred',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_event'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a Unix timestamp in seconds based on the user's description.
|
||||
Examples:
|
||||
- "now" -> Current Unix timestamp
|
||||
- "5 minutes ago" -> Current time minus 300 seconds
|
||||
|
||||
Return ONLY the numeric timestamp.`,
|
||||
placeholder: 'Describe when the event occurred (e.g., "now")...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
// Company attachment fields
|
||||
{
|
||||
id: 'attach_company_id',
|
||||
title: 'Company ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'ID of the company to attach/detach',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['attach_contact_to_company', 'detach_contact_from_company'],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
@@ -818,6 +1151,21 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
'intercom_create_ticket',
|
||||
'intercom_get_ticket',
|
||||
'intercom_create_message',
|
||||
'intercom_update_ticket_v2',
|
||||
'intercom_list_admins_v2',
|
||||
'intercom_close_conversation_v2',
|
||||
'intercom_open_conversation_v2',
|
||||
'intercom_snooze_conversation_v2',
|
||||
'intercom_assign_conversation_v2',
|
||||
'intercom_list_tags_v2',
|
||||
'intercom_create_tag_v2',
|
||||
'intercom_tag_contact_v2',
|
||||
'intercom_untag_contact_v2',
|
||||
'intercom_tag_conversation_v2',
|
||||
'intercom_create_note_v2',
|
||||
'intercom_create_event_v2',
|
||||
'intercom_attach_contact_to_company_v2',
|
||||
'intercom_detach_contact_from_company_v2',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -854,6 +1202,36 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
return 'intercom_get_ticket'
|
||||
case 'create_message':
|
||||
return 'intercom_create_message'
|
||||
case 'update_ticket':
|
||||
return 'intercom_update_ticket_v2'
|
||||
case 'list_admins':
|
||||
return 'intercom_list_admins_v2'
|
||||
case 'close_conversation':
|
||||
return 'intercom_close_conversation_v2'
|
||||
case 'open_conversation':
|
||||
return 'intercom_open_conversation_v2'
|
||||
case 'snooze_conversation':
|
||||
return 'intercom_snooze_conversation_v2'
|
||||
case 'assign_conversation':
|
||||
return 'intercom_assign_conversation_v2'
|
||||
case 'list_tags':
|
||||
return 'intercom_list_tags_v2'
|
||||
case 'create_tag':
|
||||
return 'intercom_create_tag_v2'
|
||||
case 'tag_contact':
|
||||
return 'intercom_tag_contact_v2'
|
||||
case 'untag_contact':
|
||||
return 'intercom_untag_contact_v2'
|
||||
case 'tag_conversation':
|
||||
return 'intercom_tag_conversation_v2'
|
||||
case 'create_note':
|
||||
return 'intercom_create_note_v2'
|
||||
case 'create_event':
|
||||
return 'intercom_create_event_v2'
|
||||
case 'attach_contact_to_company':
|
||||
return 'intercom_attach_contact_to_company_v2'
|
||||
case 'detach_contact_from_company':
|
||||
return 'intercom_detach_contact_from_company_v2'
|
||||
default:
|
||||
throw new Error(`Unknown operation: ${params.operation}`)
|
||||
}
|
||||
@@ -870,6 +1248,23 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
message_created_at,
|
||||
include_translations,
|
||||
disable_notifications,
|
||||
close_body,
|
||||
assign_body,
|
||||
tag_contact_id,
|
||||
attach_company_id,
|
||||
update_ticket_attributes,
|
||||
ticket_open,
|
||||
ticket_is_shared,
|
||||
ticket_snoozed_until,
|
||||
ticket_assignee_id,
|
||||
tag_name,
|
||||
tag_id_update,
|
||||
note_body,
|
||||
event_user_id,
|
||||
event_email,
|
||||
event_contact_id,
|
||||
event_metadata,
|
||||
event_created_at,
|
||||
...rest
|
||||
} = params
|
||||
const cleanParams: Record<string, any> = {}
|
||||
@@ -897,7 +1292,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
cleanParams.created_at = Number(reply_created_at)
|
||||
}
|
||||
|
||||
// Map ticket fields
|
||||
// Map ticket fields for create_ticket
|
||||
if (operation === 'create_ticket') {
|
||||
if (ticket_company_id) cleanParams.company_id = ticket_company_id
|
||||
if (ticket_created_at) cleanParams.created_at = Number(ticket_created_at)
|
||||
@@ -920,6 +1315,71 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
|
||||
cleanParams.include_translations = include_translations === 'true'
|
||||
}
|
||||
|
||||
// Map close_body to body for close_conversation
|
||||
if (operation === 'close_conversation' && close_body) {
|
||||
cleanParams.body = close_body
|
||||
}
|
||||
|
||||
// Map assign_body to body for assign_conversation
|
||||
if (operation === 'assign_conversation' && assign_body) {
|
||||
cleanParams.body = assign_body
|
||||
}
|
||||
|
||||
// Map tag_contact_id to contactId for tag/note/company attachment operations
|
||||
if (
|
||||
[
|
||||
'tag_contact',
|
||||
'untag_contact',
|
||||
'create_note',
|
||||
'attach_contact_to_company',
|
||||
'detach_contact_from_company',
|
||||
].includes(operation) &&
|
||||
tag_contact_id
|
||||
) {
|
||||
cleanParams.contactId = tag_contact_id
|
||||
}
|
||||
|
||||
// Map attach_company_id to companyId for company attachment operations
|
||||
if (
|
||||
['attach_contact_to_company', 'detach_contact_from_company'].includes(operation) &&
|
||||
attach_company_id
|
||||
) {
|
||||
cleanParams.companyId = attach_company_id
|
||||
}
|
||||
|
||||
// Map update_ticket fields
|
||||
if (operation === 'update_ticket') {
|
||||
if (update_ticket_attributes) cleanParams.ticket_attributes = update_ticket_attributes
|
||||
if (ticket_open !== undefined && ticket_open !== '') {
|
||||
cleanParams.open = ticket_open === 'true'
|
||||
}
|
||||
if (ticket_is_shared !== undefined && ticket_is_shared !== '') {
|
||||
cleanParams.is_shared = ticket_is_shared === 'true'
|
||||
}
|
||||
if (ticket_snoozed_until) cleanParams.snoozed_until = Number(ticket_snoozed_until)
|
||||
if (ticket_assignee_id) cleanParams.assignee_id = ticket_assignee_id
|
||||
}
|
||||
|
||||
// Map tag fields for create_tag
|
||||
if (operation === 'create_tag') {
|
||||
if (tag_name) cleanParams.name = tag_name
|
||||
if (tag_id_update) cleanParams.id = tag_id_update
|
||||
}
|
||||
|
||||
// Map note_body to body for create_note
|
||||
if (operation === 'create_note' && note_body) {
|
||||
cleanParams.body = note_body
|
||||
}
|
||||
|
||||
// Map event fields for create_event
|
||||
if (operation === 'create_event') {
|
||||
if (event_user_id) cleanParams.user_id = event_user_id
|
||||
if (event_email) cleanParams.email = event_email
|
||||
if (event_contact_id) cleanParams.id = event_contact_id
|
||||
if (event_metadata) cleanParams.metadata = event_metadata
|
||||
if (event_created_at) cleanParams.created_at = Number(event_created_at)
|
||||
}
|
||||
|
||||
Object.entries(rest).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
cleanParams[key] = value
|
||||
@@ -963,7 +1423,22 @@ export const IntercomV2Block: BlockConfig = {
|
||||
'intercom_search_conversations_v2',
|
||||
'intercom_create_ticket_v2',
|
||||
'intercom_get_ticket_v2',
|
||||
'intercom_update_ticket_v2',
|
||||
'intercom_create_message_v2',
|
||||
'intercom_list_admins_v2',
|
||||
'intercom_close_conversation_v2',
|
||||
'intercom_open_conversation_v2',
|
||||
'intercom_snooze_conversation_v2',
|
||||
'intercom_assign_conversation_v2',
|
||||
'intercom_list_tags_v2',
|
||||
'intercom_create_tag_v2',
|
||||
'intercom_tag_contact_v2',
|
||||
'intercom_untag_contact_v2',
|
||||
'intercom_tag_conversation_v2',
|
||||
'intercom_create_note_v2',
|
||||
'intercom_create_event_v2',
|
||||
'intercom_attach_contact_to_company_v2',
|
||||
'intercom_detach_contact_from_company_v2',
|
||||
],
|
||||
config: {
|
||||
tool: createVersionedToolSelector({
|
||||
@@ -999,8 +1474,38 @@ export const IntercomV2Block: BlockConfig = {
|
||||
return 'intercom_create_ticket'
|
||||
case 'get_ticket':
|
||||
return 'intercom_get_ticket'
|
||||
case 'update_ticket':
|
||||
return 'intercom_update_ticket'
|
||||
case 'create_message':
|
||||
return 'intercom_create_message'
|
||||
case 'list_admins':
|
||||
return 'intercom_list_admins'
|
||||
case 'close_conversation':
|
||||
return 'intercom_close_conversation'
|
||||
case 'open_conversation':
|
||||
return 'intercom_open_conversation'
|
||||
case 'snooze_conversation':
|
||||
return 'intercom_snooze_conversation'
|
||||
case 'assign_conversation':
|
||||
return 'intercom_assign_conversation'
|
||||
case 'list_tags':
|
||||
return 'intercom_list_tags'
|
||||
case 'create_tag':
|
||||
return 'intercom_create_tag'
|
||||
case 'tag_contact':
|
||||
return 'intercom_tag_contact'
|
||||
case 'untag_contact':
|
||||
return 'intercom_untag_contact'
|
||||
case 'tag_conversation':
|
||||
return 'intercom_tag_conversation'
|
||||
case 'create_note':
|
||||
return 'intercom_create_note'
|
||||
case 'create_event':
|
||||
return 'intercom_create_event'
|
||||
case 'attach_contact_to_company':
|
||||
return 'intercom_attach_contact_to_company'
|
||||
case 'detach_contact_from_company':
|
||||
return 'intercom_detach_contact_from_company'
|
||||
default:
|
||||
return 'intercom_create_contact'
|
||||
}
|
||||
@@ -1008,7 +1513,158 @@ export const IntercomV2Block: BlockConfig = {
|
||||
suffix: '_v2',
|
||||
fallbackToolId: 'intercom_create_contact_v2',
|
||||
}),
|
||||
params: IntercomBlock.tools!.config!.params,
|
||||
params: (params) => {
|
||||
const {
|
||||
operation,
|
||||
message_type_msg,
|
||||
company_name,
|
||||
contact_company_id,
|
||||
reply_created_at,
|
||||
ticket_company_id,
|
||||
ticket_created_at,
|
||||
message_created_at,
|
||||
include_translations,
|
||||
disable_notifications,
|
||||
close_body,
|
||||
assign_body,
|
||||
tag_contact_id,
|
||||
attach_company_id,
|
||||
update_ticket_attributes,
|
||||
ticket_open,
|
||||
ticket_is_shared,
|
||||
ticket_snoozed_until,
|
||||
ticket_assignee_id,
|
||||
tag_name,
|
||||
tag_id_update,
|
||||
note_body,
|
||||
event_user_id,
|
||||
event_email,
|
||||
event_contact_id,
|
||||
event_metadata,
|
||||
event_created_at,
|
||||
...rest
|
||||
} = params
|
||||
const cleanParams: Record<string, any> = {}
|
||||
|
||||
// Special mapping for message_type in create_message
|
||||
if (operation === 'create_message' && message_type_msg) {
|
||||
cleanParams.message_type = message_type_msg
|
||||
}
|
||||
|
||||
// Special mapping for company name
|
||||
if (operation === 'create_company' && company_name) {
|
||||
cleanParams.name = company_name
|
||||
}
|
||||
|
||||
// Map contact_company_id to company_id for contact operations
|
||||
if (
|
||||
(operation === 'create_contact' || operation === 'update_contact') &&
|
||||
contact_company_id
|
||||
) {
|
||||
cleanParams.company_id = contact_company_id
|
||||
}
|
||||
|
||||
// Map reply_created_at to created_at for reply_conversation
|
||||
if (operation === 'reply_conversation' && reply_created_at) {
|
||||
cleanParams.created_at = Number(reply_created_at)
|
||||
}
|
||||
|
||||
// Map ticket fields for create_ticket
|
||||
if (operation === 'create_ticket') {
|
||||
if (ticket_company_id) cleanParams.company_id = ticket_company_id
|
||||
if (ticket_created_at) cleanParams.created_at = Number(ticket_created_at)
|
||||
if (disable_notifications !== undefined && disable_notifications !== '') {
|
||||
cleanParams.disable_notifications = disable_notifications === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
// Map message_created_at to created_at for create_message
|
||||
if (operation === 'create_message' && message_created_at) {
|
||||
cleanParams.created_at = Number(message_created_at)
|
||||
}
|
||||
|
||||
// Convert include_translations string to boolean for get_conversation
|
||||
if (
|
||||
operation === 'get_conversation' &&
|
||||
include_translations !== undefined &&
|
||||
include_translations !== ''
|
||||
) {
|
||||
cleanParams.include_translations = include_translations === 'true'
|
||||
}
|
||||
|
||||
// Map close_body to body for close_conversation
|
||||
if (operation === 'close_conversation' && close_body) {
|
||||
cleanParams.body = close_body
|
||||
}
|
||||
|
||||
// Map assign_body to body for assign_conversation
|
||||
if (operation === 'assign_conversation' && assign_body) {
|
||||
cleanParams.body = assign_body
|
||||
}
|
||||
|
||||
// Map tag_contact_id to contactId for tag/note/company attachment operations
|
||||
if (
|
||||
[
|
||||
'tag_contact',
|
||||
'untag_contact',
|
||||
'create_note',
|
||||
'attach_contact_to_company',
|
||||
'detach_contact_from_company',
|
||||
].includes(operation) &&
|
||||
tag_contact_id
|
||||
) {
|
||||
cleanParams.contactId = tag_contact_id
|
||||
}
|
||||
|
||||
// Map attach_company_id to companyId for company attachment operations
|
||||
if (
|
||||
['attach_contact_to_company', 'detach_contact_from_company'].includes(operation) &&
|
||||
attach_company_id
|
||||
) {
|
||||
cleanParams.companyId = attach_company_id
|
||||
}
|
||||
|
||||
// Map update_ticket fields
|
||||
if (operation === 'update_ticket') {
|
||||
if (update_ticket_attributes) cleanParams.ticket_attributes = update_ticket_attributes
|
||||
if (ticket_open !== undefined && ticket_open !== '') {
|
||||
cleanParams.open = ticket_open === 'true'
|
||||
}
|
||||
if (ticket_is_shared !== undefined && ticket_is_shared !== '') {
|
||||
cleanParams.is_shared = ticket_is_shared === 'true'
|
||||
}
|
||||
if (ticket_snoozed_until) cleanParams.snoozed_until = Number(ticket_snoozed_until)
|
||||
if (ticket_assignee_id) cleanParams.assignee_id = ticket_assignee_id
|
||||
}
|
||||
|
||||
// Map tag fields for create_tag
|
||||
if (operation === 'create_tag') {
|
||||
if (tag_name) cleanParams.name = tag_name
|
||||
if (tag_id_update) cleanParams.id = tag_id_update
|
||||
}
|
||||
|
||||
// Map note_body to body for create_note
|
||||
if (operation === 'create_note' && note_body) {
|
||||
cleanParams.body = note_body
|
||||
}
|
||||
|
||||
// Map event fields for create_event
|
||||
if (operation === 'create_event') {
|
||||
if (event_user_id) cleanParams.user_id = event_user_id
|
||||
if (event_email) cleanParams.email = event_email
|
||||
if (event_contact_id) cleanParams.id = event_contact_id
|
||||
if (event_metadata) cleanParams.metadata = event_metadata
|
||||
if (event_created_at) cleanParams.created_at = Number(event_created_at)
|
||||
}
|
||||
|
||||
Object.entries(rest).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
cleanParams[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
return cleanParams
|
||||
},
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
@@ -1031,10 +1687,23 @@ export const IntercomV2Block: BlockConfig = {
|
||||
type: 'array',
|
||||
description: 'Array of conversations (for list/search operations)',
|
||||
},
|
||||
state: { type: 'string', description: 'Conversation state (for close/open/snooze operations)' },
|
||||
ticket: { type: 'json', description: 'Ticket object with id, ticket_id, ticket_state' },
|
||||
ticketId: { type: 'string', description: 'ID of the ticket (for create operations)' },
|
||||
ticketId: { type: 'string', description: 'ID of the ticket (for create/update operations)' },
|
||||
ticket_state: { type: 'string', description: 'Ticket state (for update_ticket operation)' },
|
||||
message: { type: 'json', description: 'Message object with id, type' },
|
||||
messageId: { type: 'string', description: 'ID of the message (for create operations)' },
|
||||
admins: { type: 'array', description: 'Array of admin objects (for list_admins operation)' },
|
||||
tags: { type: 'array', description: 'Array of tag objects (for list_tags operation)' },
|
||||
tag: { type: 'json', description: 'Tag object with id and name (for tag operations)' },
|
||||
tagId: { type: 'string', description: 'ID of the tag (for create_tag operation)' },
|
||||
note: { type: 'json', description: 'Note object with id and body (for create_note operation)' },
|
||||
noteId: { type: 'string', description: 'ID of the note (for create_note operation)' },
|
||||
event_name: {
|
||||
type: 'string',
|
||||
description: 'Name of the tracked event (for create_event operation)',
|
||||
},
|
||||
name: { type: 'string', description: 'Name of the resource (for various operations)' },
|
||||
total_count: { type: 'number', description: 'Total count (for list/search operations)' },
|
||||
pages: { type: 'json', description: 'Pagination info with page, per_page, total_pages' },
|
||||
id: { type: 'string', description: 'ID of the deleted item (for delete operations)' },
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { KalshiIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
|
||||
export const KalshiBlock: BlockConfig = {
|
||||
type: 'kalshi',
|
||||
name: 'Kalshi',
|
||||
name: 'Kalshi (Legacy)',
|
||||
description: 'Access prediction markets and trade on Kalshi',
|
||||
longDescription:
|
||||
'Integrate Kalshi prediction markets into the workflow. Can get markets, market, events, event, balance, positions, orders, orderbook, trades, candlesticks, fills, series, exchange status, and place/cancel/amend trades.',
|
||||
docsLink: 'https://docs.sim.ai/tools/kalshi',
|
||||
authMode: AuthMode.ApiKey,
|
||||
category: 'tools',
|
||||
hideFromToolbar: true,
|
||||
bgColor: '#09C285',
|
||||
icon: KalshiIcon,
|
||||
subBlocks: [
|
||||
@@ -349,8 +351,14 @@ Return ONLY the numeric timestamp (seconds since Unix epoch) - no explanations,
|
||||
id: 'count',
|
||||
title: 'Contracts',
|
||||
type: 'short-input',
|
||||
placeholder: 'Number of contracts',
|
||||
required: true,
|
||||
placeholder: 'Number of contracts (or use countFp)',
|
||||
condition: { field: 'operation', value: ['create_order'] },
|
||||
},
|
||||
{
|
||||
id: 'countFp',
|
||||
title: 'Contracts (Fixed-Point)',
|
||||
type: 'short-input',
|
||||
placeholder: 'Fixed-point count (e.g., "10.50")',
|
||||
condition: { field: 'operation', value: ['create_order'] },
|
||||
},
|
||||
{
|
||||
@@ -674,3 +682,143 @@ Return ONLY the numeric timestamp (seconds since Unix epoch) - no explanations,
|
||||
paging: { type: 'json', description: 'Pagination cursor for fetching more results' },
|
||||
},
|
||||
}
|
||||
|
||||
export const KalshiV2Block: BlockConfig = {
|
||||
...KalshiBlock,
|
||||
type: 'kalshi_v2',
|
||||
name: 'Kalshi',
|
||||
description: 'Access prediction markets and trade on Kalshi',
|
||||
longDescription:
|
||||
'Integrate Kalshi prediction markets into the workflow. Can get markets, market, events, event, balance, positions, orders, orderbook, trades, candlesticks, fills, series, exchange status, and place/cancel/amend trades.',
|
||||
hideFromToolbar: false,
|
||||
tools: {
|
||||
...KalshiBlock.tools,
|
||||
access: [
|
||||
'kalshi_get_markets_v2',
|
||||
'kalshi_get_market_v2',
|
||||
'kalshi_get_events_v2',
|
||||
'kalshi_get_event_v2',
|
||||
'kalshi_get_balance_v2',
|
||||
'kalshi_get_positions_v2',
|
||||
'kalshi_get_orders_v2',
|
||||
'kalshi_get_order_v2',
|
||||
'kalshi_get_orderbook_v2',
|
||||
'kalshi_get_trades_v2',
|
||||
'kalshi_get_candlesticks_v2',
|
||||
'kalshi_get_fills_v2',
|
||||
'kalshi_get_series_by_ticker_v2',
|
||||
'kalshi_get_exchange_status_v2',
|
||||
'kalshi_create_order_v2',
|
||||
'kalshi_cancel_order_v2',
|
||||
'kalshi_amend_order_v2',
|
||||
],
|
||||
config: {
|
||||
...KalshiBlock.tools!.config,
|
||||
tool: createVersionedToolSelector({
|
||||
baseToolSelector: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'get_markets':
|
||||
return 'kalshi_get_markets'
|
||||
case 'get_market':
|
||||
return 'kalshi_get_market'
|
||||
case 'get_events':
|
||||
return 'kalshi_get_events'
|
||||
case 'get_event':
|
||||
return 'kalshi_get_event'
|
||||
case 'get_balance':
|
||||
return 'kalshi_get_balance'
|
||||
case 'get_positions':
|
||||
return 'kalshi_get_positions'
|
||||
case 'get_orders':
|
||||
return 'kalshi_get_orders'
|
||||
case 'get_order':
|
||||
return 'kalshi_get_order'
|
||||
case 'get_orderbook':
|
||||
return 'kalshi_get_orderbook'
|
||||
case 'get_trades':
|
||||
return 'kalshi_get_trades'
|
||||
case 'get_candlesticks':
|
||||
return 'kalshi_get_candlesticks'
|
||||
case 'get_fills':
|
||||
return 'kalshi_get_fills'
|
||||
case 'get_series_by_ticker':
|
||||
return 'kalshi_get_series_by_ticker'
|
||||
case 'get_exchange_status':
|
||||
return 'kalshi_get_exchange_status'
|
||||
case 'create_order':
|
||||
return 'kalshi_create_order'
|
||||
case 'cancel_order':
|
||||
return 'kalshi_cancel_order'
|
||||
case 'amend_order':
|
||||
return 'kalshi_amend_order'
|
||||
default:
|
||||
return 'kalshi_get_markets'
|
||||
}
|
||||
},
|
||||
suffix: '_v2',
|
||||
fallbackToolId: 'kalshi_get_markets_v2',
|
||||
}),
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
// List operations (V2 uses snake_case and flat cursor)
|
||||
markets: { type: 'json', description: 'Array of market objects (get_markets)' },
|
||||
events: { type: 'json', description: 'Array of event objects (get_events)' },
|
||||
orders: { type: 'json', description: 'Array of order objects (get_orders)' },
|
||||
market_positions: {
|
||||
type: 'json',
|
||||
description: 'Array of market position objects (get_positions)',
|
||||
},
|
||||
event_positions: {
|
||||
type: 'json',
|
||||
description: 'Array of event position objects (get_positions)',
|
||||
},
|
||||
fills: { type: 'json', description: 'Array of fill objects (get_fills)' },
|
||||
trades: { type: 'json', description: 'Array of trade objects (get_trades)' },
|
||||
candlesticks: {
|
||||
type: 'json',
|
||||
description: 'Array of candlestick data with yes_bid/yes_ask/price nested objects',
|
||||
},
|
||||
milestones: {
|
||||
type: 'json',
|
||||
description: 'Array of milestone objects (get_events with milestones)',
|
||||
},
|
||||
// Single item operations
|
||||
market: { type: 'json', description: 'Single market object (get_market)' },
|
||||
event: { type: 'json', description: 'Single event object (get_event)' },
|
||||
order: {
|
||||
type: 'json',
|
||||
description: 'Order object with _dollars and _fp fields (get_order, create_order, etc.)',
|
||||
},
|
||||
series: { type: 'json', description: 'Series object (get_series_by_ticker)' },
|
||||
// Account operations
|
||||
balance: { type: 'number', description: 'Account balance in cents (get_balance)' },
|
||||
portfolio_value: { type: 'number', description: 'Portfolio value in cents (get_balance)' },
|
||||
updated_ts: { type: 'number', description: 'Unix timestamp of last update (get_balance)' },
|
||||
// Orderbook (V2 uses tuple arrays)
|
||||
orderbook: {
|
||||
type: 'json',
|
||||
description: 'Orderbook with yes/no/yes_dollars/no_dollars tuple arrays',
|
||||
},
|
||||
orderbook_fp: {
|
||||
type: 'json',
|
||||
description: 'Fixed-point orderbook with yes_dollars/no_dollars tuple arrays',
|
||||
},
|
||||
// Exchange status
|
||||
exchange_status: {
|
||||
type: 'string',
|
||||
description: 'Exchange status string (get_exchange_status)',
|
||||
},
|
||||
trading_active: { type: 'boolean', description: 'Trading active flag (get_exchange_status)' },
|
||||
// Cancel order specific
|
||||
reduced_by: { type: 'number', description: 'Number of contracts reduced (cancel_order)' },
|
||||
reduced_by_fp: {
|
||||
type: 'string',
|
||||
description: 'Contracts reduced in fixed-point (cancel_order)',
|
||||
},
|
||||
// Candlesticks ticker
|
||||
ticker: { type: 'string', description: 'Market ticker (get_candlesticks)' },
|
||||
// Pagination (flat cursor instead of nested paging object)
|
||||
cursor: { type: 'string', description: 'Pagination cursor for fetching more results' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const PolymarketBlock: BlockConfig = {
|
||||
name: 'Polymarket',
|
||||
description: 'Access prediction markets data from Polymarket',
|
||||
longDescription:
|
||||
'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.',
|
||||
docsLink: 'https://docs.sim.ai/tools/polymarket',
|
||||
category: 'tools',
|
||||
bgColor: '#4C82FB',
|
||||
@@ -34,6 +34,9 @@ export const PolymarketBlock: BlockConfig = {
|
||||
{ label: 'Get Tick Size', id: 'get_tick_size' },
|
||||
{ label: 'Get Positions', id: 'get_positions' },
|
||||
{ label: 'Get Trades', id: 'get_trades' },
|
||||
{ label: 'Get Activity', id: 'get_activity' },
|
||||
{ label: 'Get Leaderboard', id: 'get_leaderboard' },
|
||||
{ label: 'Get Market Holders', id: 'get_holders' },
|
||||
],
|
||||
value: () => 'get_markets',
|
||||
},
|
||||
@@ -101,14 +104,281 @@ export const PolymarketBlock: BlockConfig = {
|
||||
placeholder: 'Wallet address (optional filter)',
|
||||
condition: { field: 'operation', value: ['get_trades'] },
|
||||
},
|
||||
// Market filter for positions and trades
|
||||
// Market/Event filter for positions and trades
|
||||
{
|
||||
id: 'market',
|
||||
title: 'Market ID',
|
||||
title: 'Condition ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Market ID (optional filter)',
|
||||
placeholder: 'Condition ID filter (comma-separated)',
|
||||
condition: { field: 'operation', value: ['get_positions', 'get_trades'] },
|
||||
},
|
||||
{
|
||||
id: 'positionEventId',
|
||||
title: 'Event ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Event ID filter (alternative to Condition ID)',
|
||||
condition: { field: 'operation', value: ['get_positions', 'get_trades'] },
|
||||
},
|
||||
// Positions-specific filters
|
||||
{
|
||||
id: 'sizeThreshold',
|
||||
title: 'Size Threshold',
|
||||
type: 'short-input',
|
||||
placeholder: 'Minimum position size (default: 1)',
|
||||
condition: { field: 'operation', value: ['get_positions'] },
|
||||
},
|
||||
{
|
||||
id: 'redeemable',
|
||||
title: 'Redeemable',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Redeemable Only', id: 'true' },
|
||||
{ label: 'Non-Redeemable Only', id: 'false' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_positions'] },
|
||||
},
|
||||
{
|
||||
id: 'mergeable',
|
||||
title: 'Mergeable',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Mergeable Only', id: 'true' },
|
||||
{ label: 'Non-Mergeable Only', id: 'false' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_positions'] },
|
||||
},
|
||||
{
|
||||
id: 'positionSortBy',
|
||||
title: 'Sort By',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Default', id: '' },
|
||||
{ label: 'Tokens', id: 'TOKENS' },
|
||||
{ label: 'Current Value', id: 'CURRENT' },
|
||||
{ label: 'Initial Value', id: 'INITIAL' },
|
||||
{ label: 'Cash P&L', id: 'CASHPNL' },
|
||||
{ label: 'Percent P&L', id: 'PERCENTPNL' },
|
||||
{ label: 'Title', id: 'TITLE' },
|
||||
{ label: 'Price', id: 'PRICE' },
|
||||
{ label: 'Avg Price', id: 'AVGPRICE' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_positions'] },
|
||||
},
|
||||
{
|
||||
id: 'positionSortDirection',
|
||||
title: 'Sort Direction',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Descending', id: 'DESC' },
|
||||
{ label: 'Ascending', id: 'ASC' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_positions'] },
|
||||
},
|
||||
{
|
||||
id: 'positionTitle',
|
||||
title: 'Title Filter',
|
||||
type: 'short-input',
|
||||
placeholder: 'Search by title',
|
||||
condition: { field: 'operation', value: ['get_positions'] },
|
||||
},
|
||||
// Trades-specific filters
|
||||
{
|
||||
id: 'tradeSide',
|
||||
title: 'Trade Side',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Buy', id: 'BUY' },
|
||||
{ label: 'Sell', id: 'SELL' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_trades'] },
|
||||
},
|
||||
{
|
||||
id: 'takerOnly',
|
||||
title: 'Taker Only',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes (default)', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_trades'] },
|
||||
},
|
||||
{
|
||||
id: 'filterType',
|
||||
title: 'Filter Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'None', id: '' },
|
||||
{ label: 'Cash', id: 'CASH' },
|
||||
{ label: 'Tokens', id: 'TOKENS' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_trades'] },
|
||||
},
|
||||
{
|
||||
id: 'filterAmount',
|
||||
title: 'Filter Amount',
|
||||
type: 'short-input',
|
||||
placeholder: 'Minimum amount threshold',
|
||||
condition: { field: 'operation', value: ['get_trades'] },
|
||||
},
|
||||
// Activity-specific fields
|
||||
{
|
||||
id: 'activityUser',
|
||||
title: 'User Wallet Address',
|
||||
type: 'short-input',
|
||||
placeholder: 'Wallet address (0x-prefixed)',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activityType',
|
||||
title: 'Activity Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Trade', id: 'TRADE' },
|
||||
{ label: 'Split', id: 'SPLIT' },
|
||||
{ label: 'Merge', id: 'MERGE' },
|
||||
{ label: 'Redeem', id: 'REDEEM' },
|
||||
{ label: 'Reward', id: 'REWARD' },
|
||||
{ label: 'Conversion', id: 'CONVERSION' },
|
||||
{ label: 'Maker Rebate', id: 'MAKER_REBATE' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activityMarket',
|
||||
title: 'Condition ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Condition ID filter (comma-separated)',
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activityEventId',
|
||||
title: 'Event ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Event ID filter (comma-separated)',
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activitySide',
|
||||
title: 'Trade Side',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Buy', id: 'BUY' },
|
||||
{ label: 'Sell', id: 'SELL' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activitySortBy',
|
||||
title: 'Sort By',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Timestamp', id: 'TIMESTAMP' },
|
||||
{ label: 'Tokens', id: 'TOKENS' },
|
||||
{ label: 'Cash', id: 'CASH' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activitySortDirection',
|
||||
title: 'Sort Direction',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Descending', id: 'DESC' },
|
||||
{ label: 'Ascending', id: 'ASC' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activityStart',
|
||||
title: 'Start Timestamp',
|
||||
type: 'short-input',
|
||||
placeholder: 'Unix timestamp (seconds)',
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
{
|
||||
id: 'activityEnd',
|
||||
title: 'End Timestamp',
|
||||
type: 'short-input',
|
||||
placeholder: 'Unix timestamp (seconds)',
|
||||
condition: { field: 'operation', value: ['get_activity'] },
|
||||
},
|
||||
// Leaderboard-specific fields
|
||||
{
|
||||
id: 'leaderboardCategory',
|
||||
title: 'Category',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Overall', id: 'OVERALL' },
|
||||
{ label: 'Politics', id: 'POLITICS' },
|
||||
{ label: 'Sports', id: 'SPORTS' },
|
||||
{ label: 'Crypto', id: 'CRYPTO' },
|
||||
{ label: 'Culture', id: 'CULTURE' },
|
||||
{ label: 'Mentions', id: 'MENTIONS' },
|
||||
{ label: 'Weather', id: 'WEATHER' },
|
||||
{ label: 'Economics', id: 'ECONOMICS' },
|
||||
{ label: 'Tech', id: 'TECH' },
|
||||
{ label: 'Finance', id: 'FINANCE' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_leaderboard'] },
|
||||
},
|
||||
{
|
||||
id: 'leaderboardTimePeriod',
|
||||
title: 'Time Period',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Day', id: 'DAY' },
|
||||
{ label: 'Week', id: 'WEEK' },
|
||||
{ label: 'Month', id: 'MONTH' },
|
||||
{ label: 'All Time', id: 'ALL' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_leaderboard'] },
|
||||
},
|
||||
{
|
||||
id: 'leaderboardOrderBy',
|
||||
title: 'Order By',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Profit/Loss', id: 'PNL' },
|
||||
{ label: 'Volume', id: 'VOL' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_leaderboard'] },
|
||||
},
|
||||
{
|
||||
id: 'leaderboardUser',
|
||||
title: 'User Address',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter by specific user wallet',
|
||||
condition: { field: 'operation', value: ['get_leaderboard'] },
|
||||
},
|
||||
{
|
||||
id: 'leaderboardUserName',
|
||||
title: 'Username',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter by username',
|
||||
condition: { field: 'operation', value: ['get_leaderboard'] },
|
||||
},
|
||||
// Market Holders-specific fields
|
||||
{
|
||||
id: 'holdersMarket',
|
||||
title: 'Condition ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Condition ID (comma-separated)',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: ['get_holders'] },
|
||||
},
|
||||
{
|
||||
id: 'holdersMinBalance',
|
||||
title: 'Min Balance',
|
||||
type: 'short-input',
|
||||
placeholder: 'Minimum balance threshold (default: 1)',
|
||||
condition: { field: 'operation', value: ['get_holders'] },
|
||||
},
|
||||
// Token ID for CLOB operations
|
||||
{
|
||||
id: 'tokenId',
|
||||
@@ -205,11 +475,11 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
// Filters for list operations
|
||||
{
|
||||
id: 'closed',
|
||||
title: 'Status',
|
||||
title: 'Closed Status',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All', id: '' },
|
||||
{ label: 'Active Only', id: 'false' },
|
||||
{ label: 'Open Only', id: 'false' },
|
||||
{ label: 'Closed Only', id: 'true' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['get_markets', 'get_events'] },
|
||||
@@ -269,7 +539,18 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
placeholder: 'Number of results (max 50)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_markets', 'get_events', 'get_tags', 'search', 'get_series', 'get_trades'],
|
||||
value: [
|
||||
'get_markets',
|
||||
'get_events',
|
||||
'get_tags',
|
||||
'search',
|
||||
'get_series',
|
||||
'get_trades',
|
||||
'get_positions',
|
||||
'get_activity',
|
||||
'get_leaderboard',
|
||||
'get_holders',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -279,9 +560,25 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
placeholder: 'Pagination offset',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_markets', 'get_events', 'get_tags', 'search', 'get_series', 'get_trades'],
|
||||
value: [
|
||||
'get_markets',
|
||||
'get_events',
|
||||
'get_tags',
|
||||
'get_series',
|
||||
'get_trades',
|
||||
'get_positions',
|
||||
'get_activity',
|
||||
'get_leaderboard',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'page',
|
||||
title: 'Page',
|
||||
type: 'short-input',
|
||||
placeholder: 'Page number (1-indexed)',
|
||||
condition: { field: 'operation', value: ['search'] },
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
@@ -302,6 +599,9 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
'polymarket_get_tick_size',
|
||||
'polymarket_get_positions',
|
||||
'polymarket_get_trades',
|
||||
'polymarket_get_activity',
|
||||
'polymarket_get_leaderboard',
|
||||
'polymarket_get_holders',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -340,12 +640,49 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
return 'polymarket_get_positions'
|
||||
case 'get_trades':
|
||||
return 'polymarket_get_trades'
|
||||
case 'get_activity':
|
||||
return 'polymarket_get_activity'
|
||||
case 'get_leaderboard':
|
||||
return 'polymarket_get_leaderboard'
|
||||
case 'get_holders':
|
||||
return 'polymarket_get_holders'
|
||||
default:
|
||||
return 'polymarket_get_markets'
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { operation, marketSlug, eventSlug, orderEvents, order, ...rest } = params
|
||||
const {
|
||||
operation,
|
||||
marketSlug,
|
||||
eventSlug,
|
||||
orderEvents,
|
||||
order,
|
||||
positionEventId,
|
||||
tradeSide,
|
||||
positionSortBy,
|
||||
positionSortDirection,
|
||||
positionTitle,
|
||||
// Activity params
|
||||
activityUser,
|
||||
activityType,
|
||||
activityMarket,
|
||||
activityEventId,
|
||||
activitySide,
|
||||
activitySortBy,
|
||||
activitySortDirection,
|
||||
activityStart,
|
||||
activityEnd,
|
||||
// Leaderboard params
|
||||
leaderboardCategory,
|
||||
leaderboardTimePeriod,
|
||||
leaderboardOrderBy,
|
||||
leaderboardUser,
|
||||
leaderboardUserName,
|
||||
// Holders params
|
||||
holdersMarket,
|
||||
holdersMinBalance,
|
||||
...rest
|
||||
} = params
|
||||
const cleanParams: Record<string, any> = {}
|
||||
|
||||
// Map marketSlug to slug for get_market
|
||||
@@ -365,6 +702,51 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
cleanParams.order = orderEvents
|
||||
}
|
||||
|
||||
// Map positionEventId to eventId for positions and trades
|
||||
if ((operation === 'get_positions' || operation === 'get_trades') && positionEventId) {
|
||||
cleanParams.eventId = positionEventId
|
||||
}
|
||||
|
||||
// Map tradeSide to side for trades
|
||||
if (operation === 'get_trades' && tradeSide) {
|
||||
cleanParams.side = tradeSide
|
||||
}
|
||||
|
||||
// Map position-specific fields
|
||||
if (operation === 'get_positions') {
|
||||
if (positionSortBy) cleanParams.sortBy = positionSortBy
|
||||
if (positionSortDirection) cleanParams.sortDirection = positionSortDirection
|
||||
if (positionTitle) cleanParams.title = positionTitle
|
||||
}
|
||||
|
||||
// Map activity-specific fields
|
||||
if (operation === 'get_activity') {
|
||||
if (activityUser) cleanParams.user = activityUser
|
||||
if (activityType) cleanParams.type = activityType
|
||||
if (activityMarket) cleanParams.market = activityMarket
|
||||
if (activityEventId) cleanParams.eventId = activityEventId
|
||||
if (activitySide) cleanParams.side = activitySide
|
||||
if (activitySortBy) cleanParams.sortBy = activitySortBy
|
||||
if (activitySortDirection) cleanParams.sortDirection = activitySortDirection
|
||||
if (activityStart) cleanParams.start = Number(activityStart)
|
||||
if (activityEnd) cleanParams.end = Number(activityEnd)
|
||||
}
|
||||
|
||||
// Map leaderboard-specific fields
|
||||
if (operation === 'get_leaderboard') {
|
||||
if (leaderboardCategory) cleanParams.category = leaderboardCategory
|
||||
if (leaderboardTimePeriod) cleanParams.timePeriod = leaderboardTimePeriod
|
||||
if (leaderboardOrderBy) cleanParams.orderBy = leaderboardOrderBy
|
||||
if (leaderboardUser) cleanParams.user = leaderboardUser
|
||||
if (leaderboardUserName) cleanParams.userName = leaderboardUserName
|
||||
}
|
||||
|
||||
// Map holders-specific fields
|
||||
if (operation === 'get_holders') {
|
||||
if (holdersMarket) cleanParams.market = holdersMarket
|
||||
if (holdersMinBalance) cleanParams.minBalance = holdersMinBalance
|
||||
}
|
||||
|
||||
// Convert numeric fields from string to number for get_price_history
|
||||
if (operation === 'get_price_history') {
|
||||
if (rest.fidelity) cleanParams.fidelity = Number(rest.fidelity)
|
||||
@@ -394,13 +776,55 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
seriesId: { type: 'string', description: 'Series ID' },
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
user: { type: 'string', description: 'User wallet address' },
|
||||
market: { type: 'string', description: 'Market ID filter' },
|
||||
market: { type: 'string', description: 'Condition ID filter' },
|
||||
positionEventId: { type: 'string', description: 'Event ID filter for positions/trades' },
|
||||
tokenId: { type: 'string', description: 'CLOB Token ID' },
|
||||
side: { type: 'string', description: 'Order side (buy/sell)' },
|
||||
interval: { type: 'string', description: 'Price history interval' },
|
||||
fidelity: { type: 'number', description: 'Data resolution in minutes' },
|
||||
startTs: { type: 'number', description: 'Start timestamp (Unix)' },
|
||||
endTs: { type: 'number', description: 'End timestamp (Unix)' },
|
||||
// Positions-specific inputs
|
||||
sizeThreshold: { type: 'string', description: 'Minimum position size threshold' },
|
||||
redeemable: { type: 'string', description: 'Filter by redeemable status' },
|
||||
mergeable: { type: 'string', description: 'Filter by mergeable status' },
|
||||
positionSortBy: { type: 'string', description: 'Sort positions by field' },
|
||||
positionSortDirection: { type: 'string', description: 'Sort direction (ASC/DESC)' },
|
||||
positionTitle: { type: 'string', description: 'Filter positions by title' },
|
||||
// Trades-specific inputs
|
||||
tradeSide: { type: 'string', description: 'Filter trades by side (BUY/SELL)' },
|
||||
takerOnly: { type: 'string', description: 'Filter to taker trades only' },
|
||||
filterType: { type: 'string', description: 'Trade filter type (CASH/TOKENS)' },
|
||||
filterAmount: { type: 'string', description: 'Minimum trade amount threshold' },
|
||||
// List operation filters
|
||||
closed: { type: 'string', description: 'Filter by closed status' },
|
||||
order: { type: 'string', description: 'Sort field for markets' },
|
||||
orderEvents: { type: 'string', description: 'Sort field for events' },
|
||||
ascending: { type: 'string', description: 'Sort order (true/false)' },
|
||||
tagId: { type: 'string', description: 'Filter by tag ID' },
|
||||
// Pagination
|
||||
limit: { type: 'string', description: 'Number of results per page' },
|
||||
offset: { type: 'string', description: 'Pagination offset' },
|
||||
page: { type: 'string', description: 'Page number for search' },
|
||||
// Activity-specific inputs
|
||||
activityUser: { type: 'string', description: 'User wallet address for activity' },
|
||||
activityType: { type: 'string', description: 'Activity type filter' },
|
||||
activityMarket: { type: 'string', description: 'Condition ID filter for activity' },
|
||||
activityEventId: { type: 'string', description: 'Event ID filter for activity' },
|
||||
activitySide: { type: 'string', description: 'Trade side filter for activity' },
|
||||
activitySortBy: { type: 'string', description: 'Sort field for activity' },
|
||||
activitySortDirection: { type: 'string', description: 'Sort direction for activity' },
|
||||
activityStart: { type: 'string', description: 'Start timestamp for activity' },
|
||||
activityEnd: { type: 'string', description: 'End timestamp for activity' },
|
||||
// Leaderboard-specific inputs
|
||||
leaderboardCategory: { type: 'string', description: 'Leaderboard category' },
|
||||
leaderboardTimePeriod: { type: 'string', description: 'Leaderboard time period' },
|
||||
leaderboardOrderBy: { type: 'string', description: 'Leaderboard order by field' },
|
||||
leaderboardUser: { type: 'string', description: 'Filter leaderboard by user' },
|
||||
leaderboardUserName: { type: 'string', description: 'Filter leaderboard by username' },
|
||||
// Holders-specific inputs
|
||||
holdersMarket: { type: 'string', description: 'Condition ID for holders lookup' },
|
||||
holdersMinBalance: { type: 'string', description: 'Minimum balance threshold' },
|
||||
},
|
||||
outputs: {
|
||||
// List operations
|
||||
@@ -422,11 +846,19 @@ Return ONLY the Unix timestamp as a number - no explanations, no quotes, no extr
|
||||
description: 'Search results with markets, events, profiles (search)',
|
||||
},
|
||||
// CLOB operations
|
||||
orderbook: { type: 'json', description: 'Order book with bids and asks (get_orderbook)' },
|
||||
orderbook: {
|
||||
type: 'json',
|
||||
description: 'Order book with bids and asks (get_orderbook)',
|
||||
},
|
||||
price: { type: 'string', description: 'Market price (get_price, get_last_trade_price)' },
|
||||
side: { type: 'string', description: 'Last trade side - BUY or SELL (get_last_trade_price)' },
|
||||
midpoint: { type: 'string', description: 'Midpoint price (get_midpoint)' },
|
||||
history: { type: 'json', description: 'Price history entries (get_price_history)' },
|
||||
spread: { type: 'json', description: 'Bid-ask spread (get_spread)' },
|
||||
spread: { type: 'json', description: 'Spread value object (get_spread)' },
|
||||
tickSize: { type: 'string', description: 'Minimum tick size (get_tick_size)' },
|
||||
// Data API operations
|
||||
activity: { type: 'json', description: 'Array of user activity entries (get_activity)' },
|
||||
leaderboard: { type: 'json', description: 'Array of leaderboard entries (get_leaderboard)' },
|
||||
holders: { type: 'json', description: 'Array of market holder groups (get_holders)' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ import { IntercomBlock, IntercomV2Block } from '@/blocks/blocks/intercom'
|
||||
import { JinaBlock } from '@/blocks/blocks/jina'
|
||||
import { JiraBlock } from '@/blocks/blocks/jira'
|
||||
import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management'
|
||||
import { KalshiBlock } from '@/blocks/blocks/kalshi'
|
||||
import { KalshiBlock, KalshiV2Block } from '@/blocks/blocks/kalshi'
|
||||
import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
|
||||
import { LangsmithBlock } from '@/blocks/blocks/langsmith'
|
||||
import { LemlistBlock } from '@/blocks/blocks/lemlist'
|
||||
@@ -222,6 +222,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
jira: JiraBlock,
|
||||
jira_service_management: JiraServiceManagementBlock,
|
||||
kalshi: KalshiBlock,
|
||||
kalshi_v2: KalshiV2Block,
|
||||
knowledge: KnowledgeBlock,
|
||||
langsmith: LangsmithBlock,
|
||||
lemlist: LemlistBlock,
|
||||
|
||||
@@ -5,9 +5,8 @@ import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
/**
|
||||
* Custom switch component with thin track design.
|
||||
* Track: 28px width, 6px height, 20px border-radius
|
||||
* Thumb: 14px diameter circle that overlaps the track
|
||||
* Switch component styled to match Sim's design system.
|
||||
* Uses brand color for checked state, neutral border for unchecked.
|
||||
*/
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
@@ -16,21 +15,13 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-[17px] w-[30px] shrink-0 cursor-pointer items-center rounded-[20px] transition-colors focus-visible:outline-none',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||
'bg-[var(--border-1)] data-[state=checked]:bg-[var(--text-primary)]',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--brand-tertiary-2)] data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-[14px] w-[14px] rounded-full shadow-sm ring-0 transition-transform',
|
||||
'bg-[var(--white)]',
|
||||
'data-[state=checked]:translate-x-[14px] data-[state=unchecked]:translate-x-[2px]'
|
||||
)}
|
||||
/>
|
||||
<SwitchPrimitives.Thumb className='pointer-events-none block h-4 w-4 rounded-full bg-[var(--white)] shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5' />
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
|
||||
|
||||
@@ -275,6 +275,26 @@ export function isTriggerBlockType(blockType: string | undefined): boolean {
|
||||
return blockType !== undefined && (TRIGGER_BLOCK_TYPES as readonly string[]).includes(blockType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a block behaves as a trigger based on its metadata and config.
|
||||
* This is used for execution flow decisions where trigger-like behavior matters.
|
||||
*
|
||||
* A block is considered trigger-like if:
|
||||
* - Its category is 'triggers'
|
||||
* - It has triggerMode enabled
|
||||
* - It's a starter block (legacy entry point)
|
||||
*/
|
||||
export function isTriggerBehavior(block: {
|
||||
metadata?: { category?: string; id?: string }
|
||||
config?: { params?: { triggerMode?: boolean } }
|
||||
}): boolean {
|
||||
return (
|
||||
block.metadata?.category === 'triggers' ||
|
||||
block.config?.params?.triggerMode === true ||
|
||||
block.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
}
|
||||
|
||||
export function isMetadataOnlyBlockType(blockType: string | undefined): boolean {
|
||||
return (
|
||||
blockType !== undefined && (METADATA_ONLY_BLOCK_TYPES as readonly string[]).includes(blockType)
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
DEFAULTS,
|
||||
EDGE,
|
||||
isSentinelBlockType,
|
||||
isTriggerBehavior,
|
||||
isWorkflowBlockType,
|
||||
} from '@/executor/constants'
|
||||
import type { DAGNode } from '@/executor/dag/builder'
|
||||
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
|
||||
@@ -153,8 +155,8 @@ export class BlockExecutor {
|
||||
this.state.setBlockOutput(node.id, normalizedOutput, duration)
|
||||
|
||||
if (!isSentinel) {
|
||||
const filteredOutput = this.filterOutputForLog(block, normalizedOutput)
|
||||
this.callOnBlockComplete(ctx, node, block, resolvedInputs, filteredOutput, duration)
|
||||
const displayOutput = this.filterOutputForDisplay(block, normalizedOutput)
|
||||
this.callOnBlockComplete(ctx, node, block, resolvedInputs, displayOutput, duration)
|
||||
}
|
||||
|
||||
return normalizedOutput
|
||||
@@ -244,7 +246,8 @@ export class BlockExecutor {
|
||||
)
|
||||
|
||||
if (!isSentinel) {
|
||||
this.callOnBlockComplete(ctx, node, block, input, errorOutput, duration)
|
||||
const displayOutput = this.filterOutputForDisplay(block, errorOutput)
|
||||
this.callOnBlockComplete(ctx, node, block, input, displayOutput, duration)
|
||||
}
|
||||
|
||||
const hasErrorPort = this.hasErrorPortEdge(node)
|
||||
@@ -336,7 +339,9 @@ export class BlockExecutor {
|
||||
block: SerializedBlock,
|
||||
output: NormalizedBlockOutput
|
||||
): NormalizedBlockOutput {
|
||||
if (block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP) {
|
||||
const blockType = block.metadata?.id
|
||||
|
||||
if (blockType === BlockType.HUMAN_IN_THE_LOOP) {
|
||||
const filtered: NormalizedBlockOutput = {}
|
||||
for (const [key, value] of Object.entries(output)) {
|
||||
if (key.startsWith('_')) continue
|
||||
@@ -346,12 +351,7 @@ export class BlockExecutor {
|
||||
return filtered
|
||||
}
|
||||
|
||||
const isTrigger =
|
||||
block.metadata?.category === 'triggers' ||
|
||||
block.config?.params?.triggerMode === true ||
|
||||
block.metadata?.id === BlockType.STARTER
|
||||
|
||||
if (isTrigger) {
|
||||
if (isTriggerBehavior(block)) {
|
||||
const filtered: NormalizedBlockOutput = {}
|
||||
const internalKeys = ['webhook', 'workflowId']
|
||||
for (const [key, value] of Object.entries(output)) {
|
||||
@@ -364,6 +364,22 @@ export class BlockExecutor {
|
||||
return output
|
||||
}
|
||||
|
||||
private filterOutputForDisplay(
|
||||
block: SerializedBlock,
|
||||
output: NormalizedBlockOutput
|
||||
): NormalizedBlockOutput {
|
||||
const filtered = this.filterOutputForLog(block, output)
|
||||
|
||||
if (isWorkflowBlockType(block.metadata?.id)) {
|
||||
const { childTraceSpans: _, ...displayOutput } = filtered as {
|
||||
childTraceSpans?: unknown
|
||||
} & Record<string, unknown>
|
||||
return displayOutput
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void {
|
||||
const blockId = node.id
|
||||
const blockName = block.metadata?.name ?? blockId
|
||||
|
||||
@@ -773,6 +773,176 @@ describe('EdgeManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple error ports to same target', () => {
|
||||
it('should mark target ready when one source errors and another succeeds', () => {
|
||||
// This tests the case where a node has multiple incoming error edges
|
||||
// from different sources. When one source errors (activating its error edge)
|
||||
// and another source succeeds (deactivating its error edge), the target
|
||||
// should become ready after both sources complete.
|
||||
//
|
||||
// Workflow 1 (errors) ─── error ───┐
|
||||
// ├──→ Error Handler
|
||||
// Workflow 7 (succeeds) ─ error ───┘
|
||||
|
||||
const workflow1Id = 'workflow-1'
|
||||
const workflow7Id = 'workflow-7'
|
||||
const errorHandlerId = 'error-handler'
|
||||
|
||||
const workflow1Node = createMockNode(workflow1Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const workflow7Node = createMockNode(workflow7Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
|
||||
|
||||
const nodes = new Map<string, DAGNode>([
|
||||
[workflow1Id, workflow1Node],
|
||||
[workflow7Id, workflow7Node],
|
||||
[errorHandlerId, errorHandlerNode],
|
||||
])
|
||||
|
||||
const dag = createMockDAG(nodes)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
// Workflow 1 errors first - error edge activates
|
||||
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
|
||||
error: 'Something went wrong',
|
||||
})
|
||||
// Error handler should NOT be ready yet (waiting for workflow 7)
|
||||
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
|
||||
|
||||
// Workflow 7 succeeds - error edge deactivates
|
||||
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
|
||||
result: 'success',
|
||||
})
|
||||
// Error handler SHOULD be ready now (workflow 1's error edge activated)
|
||||
expect(readyAfterWorkflow7).toContain(errorHandlerId)
|
||||
})
|
||||
|
||||
it('should mark target ready when first source succeeds then second errors', () => {
|
||||
// Opposite order: first source succeeds, then second errors
|
||||
|
||||
const workflow1Id = 'workflow-1'
|
||||
const workflow7Id = 'workflow-7'
|
||||
const errorHandlerId = 'error-handler'
|
||||
|
||||
const workflow1Node = createMockNode(workflow1Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const workflow7Node = createMockNode(workflow7Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
|
||||
|
||||
const nodes = new Map<string, DAGNode>([
|
||||
[workflow1Id, workflow1Node],
|
||||
[workflow7Id, workflow7Node],
|
||||
[errorHandlerId, errorHandlerNode],
|
||||
])
|
||||
|
||||
const dag = createMockDAG(nodes)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
// Workflow 1 succeeds first - error edge deactivates
|
||||
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
|
||||
result: 'success',
|
||||
})
|
||||
// Error handler should NOT be ready yet (waiting for workflow 7)
|
||||
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
|
||||
|
||||
// Workflow 7 errors - error edge activates
|
||||
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
|
||||
error: 'Something went wrong',
|
||||
})
|
||||
// Error handler SHOULD be ready now (workflow 7's error edge activated)
|
||||
expect(readyAfterWorkflow7).toContain(errorHandlerId)
|
||||
})
|
||||
|
||||
it('should NOT mark target ready when all sources succeed (no errors)', () => {
|
||||
// When neither source errors, the error handler should NOT run
|
||||
|
||||
const workflow1Id = 'workflow-1'
|
||||
const workflow7Id = 'workflow-7'
|
||||
const errorHandlerId = 'error-handler'
|
||||
|
||||
const workflow1Node = createMockNode(workflow1Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const workflow7Node = createMockNode(workflow7Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
|
||||
|
||||
const nodes = new Map<string, DAGNode>([
|
||||
[workflow1Id, workflow1Node],
|
||||
[workflow7Id, workflow7Node],
|
||||
[errorHandlerId, errorHandlerNode],
|
||||
])
|
||||
|
||||
const dag = createMockDAG(nodes)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
// Both workflows succeed - both error edges deactivate
|
||||
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
|
||||
result: 'success',
|
||||
})
|
||||
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
|
||||
|
||||
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
|
||||
result: 'success',
|
||||
})
|
||||
// Error handler should NOT be ready (no errors occurred)
|
||||
expect(readyAfterWorkflow7).not.toContain(errorHandlerId)
|
||||
})
|
||||
|
||||
it('should mark target ready when both sources error', () => {
|
||||
// When both sources error, the error handler should run
|
||||
|
||||
const workflow1Id = 'workflow-1'
|
||||
const workflow7Id = 'workflow-7'
|
||||
const errorHandlerId = 'error-handler'
|
||||
|
||||
const workflow1Node = createMockNode(workflow1Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const workflow7Node = createMockNode(workflow7Id, [
|
||||
{ target: errorHandlerId, sourceHandle: 'error' },
|
||||
])
|
||||
|
||||
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
|
||||
|
||||
const nodes = new Map<string, DAGNode>([
|
||||
[workflow1Id, workflow1Node],
|
||||
[workflow7Id, workflow7Node],
|
||||
[errorHandlerId, errorHandlerNode],
|
||||
])
|
||||
|
||||
const dag = createMockDAG(nodes)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
// Workflow 1 errors
|
||||
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
|
||||
error: 'Error 1',
|
||||
})
|
||||
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
|
||||
|
||||
// Workflow 7 errors
|
||||
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
|
||||
error: 'Error 2',
|
||||
})
|
||||
// Error handler SHOULD be ready (both edges activated)
|
||||
expect(readyAfterWorkflow7).toContain(errorHandlerId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chained conditions', () => {
|
||||
it('should handle sequential conditions (condition1 → condition2)', () => {
|
||||
const condition1Id = 'condition-1'
|
||||
|
||||
@@ -8,6 +8,7 @@ const logger = createLogger('EdgeManager')
|
||||
|
||||
export class EdgeManager {
|
||||
private deactivatedEdges = new Set<string>()
|
||||
private nodesWithActivatedEdge = new Set<string>()
|
||||
|
||||
constructor(private dag: DAG) {}
|
||||
|
||||
@@ -35,6 +36,11 @@ export class EdgeManager {
|
||||
activatedTargets.push(edge.target)
|
||||
}
|
||||
|
||||
// Track nodes that have received at least one activated edge
|
||||
for (const targetId of activatedTargets) {
|
||||
this.nodesWithActivatedEdge.add(targetId)
|
||||
}
|
||||
|
||||
const cascadeTargets = new Set<string>()
|
||||
for (const { target, handle } of edgesToDeactivate) {
|
||||
this.deactivateEdgeAndDescendants(node.id, target, handle, cascadeTargets)
|
||||
@@ -71,6 +77,18 @@ export class EdgeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any deactivation targets that previously received an activated edge are now ready
|
||||
for (const { target } of edgesToDeactivate) {
|
||||
if (
|
||||
!readyNodes.includes(target) &&
|
||||
!activatedTargets.includes(target) &&
|
||||
this.nodesWithActivatedEdge.has(target) &&
|
||||
this.isTargetReady(target)
|
||||
) {
|
||||
readyNodes.push(target)
|
||||
}
|
||||
}
|
||||
|
||||
return readyNodes
|
||||
}
|
||||
|
||||
@@ -90,6 +108,7 @@ export class EdgeManager {
|
||||
|
||||
clearDeactivatedEdges(): void {
|
||||
this.deactivatedEdges.clear()
|
||||
this.nodesWithActivatedEdge.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +127,10 @@ export class EdgeManager {
|
||||
for (const edgeKey of edgesToRemove) {
|
||||
this.deactivatedEdges.delete(edgeKey)
|
||||
}
|
||||
// Also clear activated edge tracking for these nodes
|
||||
for (const nodeId of nodeIds) {
|
||||
this.nodesWithActivatedEdge.delete(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
private isTargetReady(targetId: string): boolean {
|
||||
@@ -210,7 +233,11 @@ export class EdgeManager {
|
||||
cascadeTargets?.add(targetId)
|
||||
}
|
||||
|
||||
if (this.hasActiveIncomingEdges(targetNode, edgeKey)) {
|
||||
// Don't cascade if node has active incoming edges OR has received an activated edge
|
||||
if (
|
||||
this.hasActiveIncomingEdges(targetNode, edgeKey) ||
|
||||
this.nodesWithActivatedEdge.has(targetId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { BlockType, isTriggerBehavior } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
@@ -7,15 +7,7 @@ const logger = createLogger('TriggerBlockHandler')
|
||||
|
||||
export class TriggerBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
if (block.metadata?.id === BlockType.STARTER) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isTriggerCategory = block.metadata?.category === 'triggers'
|
||||
|
||||
const hasTriggerMode = block.config?.params?.triggerMode === true
|
||||
|
||||
return isTriggerCategory || hasTriggerMode
|
||||
return isTriggerBehavior(block)
|
||||
}
|
||||
|
||||
async execute(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { OutputSchema } from '@/executor/utils/block-reference'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
@@ -11,25 +12,73 @@ export interface BlockDataCollection {
|
||||
blockOutputSchemas: Record<string, OutputSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Block types where inputFormat fields should be merged into outputs schema.
|
||||
* These are blocks where users define custom fields via inputFormat that become
|
||||
* valid output paths (e.g., <start.myField>, <webhook1.customField>, <hitl1.resumeField>).
|
||||
*
|
||||
* Note: This includes non-trigger blocks like 'starter' and 'human_in_the_loop' which
|
||||
* have category 'blocks' but still need their inputFormat exposed as outputs.
|
||||
*/
|
||||
const BLOCKS_WITH_INPUT_FORMAT_OUTPUTS = [
|
||||
'start_trigger',
|
||||
'starter',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
'generic_webhook',
|
||||
'human_in_the_loop',
|
||||
] as const
|
||||
|
||||
function getInputFormatFields(block: SerializedBlock): OutputSchema {
|
||||
const inputFormat = normalizeInputFormatValue(block.config?.params?.inputFormat)
|
||||
if (inputFormat.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const schema: OutputSchema = {}
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
schema[field.name] = {
|
||||
type: (field.type || 'any') as 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any',
|
||||
}
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
export function getBlockSchema(
|
||||
block: SerializedBlock,
|
||||
toolConfig?: ToolConfig
|
||||
): OutputSchema | undefined {
|
||||
const isTrigger =
|
||||
block.metadata?.category === 'triggers' ||
|
||||
(block.config?.params as Record<string, unknown> | undefined)?.triggerMode === true
|
||||
const blockType = block.metadata?.id
|
||||
|
||||
// For blocks that expose inputFormat as outputs, always merge them
|
||||
// This includes both triggers (start_trigger, generic_webhook) and
|
||||
// non-triggers (starter, human_in_the_loop) that have inputFormat
|
||||
if (
|
||||
blockType &&
|
||||
BLOCKS_WITH_INPUT_FORMAT_OUTPUTS.includes(
|
||||
blockType as (typeof BLOCKS_WITH_INPUT_FORMAT_OUTPUTS)[number]
|
||||
)
|
||||
) {
|
||||
const baseOutputs = (block.outputs as OutputSchema) || {}
|
||||
const inputFormatFields = getInputFormatFields(block)
|
||||
const merged = { ...baseOutputs, ...inputFormatFields }
|
||||
if (Object.keys(merged).length > 0) {
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
const isTrigger = isTriggerBehavior(block)
|
||||
|
||||
// Triggers use saved outputs (defines the trigger payload schema)
|
||||
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
|
||||
return block.outputs as OutputSchema
|
||||
}
|
||||
|
||||
// When a tool is selected, tool outputs are the source of truth
|
||||
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
|
||||
return toolConfig.outputs as OutputSchema
|
||||
}
|
||||
|
||||
// Fallback to saved outputs for blocks without tools
|
||||
if (block.outputs && Object.keys(block.outputs).length > 0) {
|
||||
return block.outputs as OutputSchema
|
||||
}
|
||||
|
||||
48
apps/sim/executor/utils/code-formatting.ts
Normal file
48
apps/sim/executor/utils/code-formatting.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Formats a JavaScript/TypeScript value as a code literal for the target language.
|
||||
* Handles special cases like null, undefined, booleans, and Python-specific number representations.
|
||||
*
|
||||
* @param value - The value to format
|
||||
* @param language - Target language ('javascript' or 'python')
|
||||
* @returns A string literal representation valid in the target language
|
||||
*
|
||||
* @example
|
||||
* formatLiteralForCode(null, 'python') // => 'None'
|
||||
* formatLiteralForCode(true, 'python') // => 'True'
|
||||
* formatLiteralForCode(NaN, 'python') // => "float('nan')"
|
||||
* formatLiteralForCode("hello", 'javascript') // => '"hello"'
|
||||
* formatLiteralForCode({a: 1}, 'python') // => "json.loads('{\"a\":1}')"
|
||||
*/
|
||||
export function formatLiteralForCode(value: unknown, language: 'javascript' | 'python'): string {
|
||||
const isPython = language === 'python'
|
||||
|
||||
if (value === undefined) {
|
||||
return isPython ? 'None' : 'undefined'
|
||||
}
|
||||
if (value === null) {
|
||||
return isPython ? 'None' : 'null'
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return isPython ? (value ? 'True' : 'False') : String(value)
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isNaN(value)) {
|
||||
return isPython ? "float('nan')" : 'NaN'
|
||||
}
|
||||
if (value === Number.POSITIVE_INFINITY) {
|
||||
return isPython ? "float('inf')" : 'Infinity'
|
||||
}
|
||||
if (value === Number.NEGATIVE_INFINITY) {
|
||||
return isPython ? "float('-inf')" : '-Infinity'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
// Objects and arrays - Python needs json.loads() because JSON true/false/null aren't valid Python
|
||||
if (isPython) {
|
||||
return `json.loads(${JSON.stringify(JSON.stringify(value))})`
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
@@ -157,7 +157,14 @@ export class VariableResolver {
|
||||
|
||||
let replacementError: Error | null = null
|
||||
|
||||
// Use generic utility for smart variable reference replacement
|
||||
const blockType = block?.metadata?.id
|
||||
const language =
|
||||
blockType === BlockType.FUNCTION
|
||||
? ((block?.config?.params as Record<string, unknown> | undefined)?.language as
|
||||
| string
|
||||
| undefined)
|
||||
: undefined
|
||||
|
||||
let result = replaceValidReferences(template, (match) => {
|
||||
if (replacementError) return match
|
||||
|
||||
@@ -167,14 +174,7 @@ export class VariableResolver {
|
||||
return match
|
||||
}
|
||||
|
||||
const blockType = block?.metadata?.id
|
||||
const isInTemplateLiteral =
|
||||
blockType === BlockType.FUNCTION &&
|
||||
template.includes('${') &&
|
||||
template.includes('}') &&
|
||||
template.includes('`')
|
||||
|
||||
return this.blockResolver.formatValueForBlock(resolved, blockType, isInTemplateLiteral)
|
||||
return this.blockResolver.formatValueForBlock(resolved, blockType, language)
|
||||
} catch (error) {
|
||||
replacementError = error instanceof Error ? error : new Error(String(error))
|
||||
return match
|
||||
|
||||
@@ -257,15 +257,9 @@ describe('BlockResolver', () => {
|
||||
expect(result).toBe('"hello"')
|
||||
})
|
||||
|
||||
it.concurrent('should format string for function block in template literal', () => {
|
||||
it.concurrent('should format object for function block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const result = resolver.formatValueForBlock('hello', 'function', true)
|
||||
expect(result).toBe('hello')
|
||||
})
|
||||
|
||||
it.concurrent('should format object for function block in template literal', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const result = resolver.formatValueForBlock({ a: 1 }, 'function', true)
|
||||
const result = resolver.formatValueForBlock({ a: 1 }, 'function')
|
||||
expect(result).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type OutputSchema,
|
||||
resolveBlockReference,
|
||||
} from '@/executor/utils/block-reference'
|
||||
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
|
||||
import {
|
||||
navigatePath,
|
||||
type ResolutionContext,
|
||||
@@ -159,17 +160,13 @@ export class BlockResolver implements Resolver {
|
||||
return this.nameToBlockId.get(normalizeName(name))
|
||||
}
|
||||
|
||||
public formatValueForBlock(
|
||||
value: any,
|
||||
blockType: string | undefined,
|
||||
isInTemplateLiteral = false
|
||||
): string {
|
||||
public formatValueForBlock(value: any, blockType: string | undefined, language?: string): string {
|
||||
if (blockType === 'condition') {
|
||||
return this.stringifyForCondition(value)
|
||||
}
|
||||
|
||||
if (blockType === 'function') {
|
||||
return this.formatValueForCodeContext(value, isInTemplateLiteral)
|
||||
return this.formatValueForCodeContext(value, language)
|
||||
}
|
||||
|
||||
if (blockType === 'response') {
|
||||
@@ -210,29 +207,7 @@ export class BlockResolver implements Resolver {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
private formatValueForCodeContext(value: any, isInTemplateLiteral: boolean): string {
|
||||
if (isInTemplateLiteral) {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (value === undefined) {
|
||||
return 'undefined'
|
||||
}
|
||||
if (value === null) {
|
||||
return 'null'
|
||||
}
|
||||
return String(value)
|
||||
private formatValueForCodeContext(value: any, language?: string): string {
|
||||
return formatLiteralForCode(value, language === 'python' ? 'python' : 'javascript')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ export function navigatePath(obj: any, path: string[]): any {
|
||||
const arrayMatch = part.match(/^([^[]+)(\[.+)$/)
|
||||
if (arrayMatch) {
|
||||
const [, prop, bracketsPart] = arrayMatch
|
||||
current = current[prop]
|
||||
current =
|
||||
typeof current === 'object' && current !== null
|
||||
? (current as Record<string, unknown>)[prop]
|
||||
: undefined
|
||||
if (current === undefined || current === null) {
|
||||
return undefined
|
||||
}
|
||||
@@ -49,7 +52,10 @@ export function navigatePath(obj: any, path: string[]): any {
|
||||
const index = Number.parseInt(part, 10)
|
||||
current = Array.isArray(current) ? current[index] : undefined
|
||||
} else {
|
||||
current = current[part]
|
||||
current =
|
||||
typeof current === 'object' && current !== null
|
||||
? (current as Record<string, unknown>)[part]
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
return current
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { Node, ReactFlowInstance } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
|
||||
interface VisibleBounds {
|
||||
width: number
|
||||
@@ -139,8 +140,8 @@ export function useCanvasViewport(reactFlowInstance: ReactFlowInstance | null) {
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeWidth = node.width ?? 200
|
||||
const nodeHeight = node.height ?? 100
|
||||
const nodeWidth = node.width ?? BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
const nodeHeight = node.height ?? BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
|
||||
minX = Math.min(minX, node.position.x)
|
||||
minY = Math.min(minY, node.position.y)
|
||||
|
||||
239
apps/sim/hooks/use-code-undo-redo.ts
Normal file
239
apps/sim/hooks/use-code-undo-redo.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCodeUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CodeUndoRedo')
|
||||
|
||||
interface UseCodeUndoRedoOptions {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
value: string
|
||||
enabled?: boolean
|
||||
isReadOnly?: boolean
|
||||
isStreaming?: boolean
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useCodeUndoRedo({
|
||||
blockId,
|
||||
subBlockId,
|
||||
value,
|
||||
enabled = true,
|
||||
isReadOnly = false,
|
||||
isStreaming = false,
|
||||
debounceMs = 500,
|
||||
}: UseCodeUndoRedoOptions) {
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const { isShowingDiff, hasActiveDiff } = useWorkflowDiffStore(
|
||||
useShallow((state) => ({
|
||||
isShowingDiff: state.isShowingDiff,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
}))
|
||||
)
|
||||
|
||||
const isBaselineView = hasActiveDiff && !isShowingDiff
|
||||
const isEnabled = useMemo(
|
||||
() => Boolean(enabled && activeWorkflowId && !isReadOnly && !isStreaming && !isBaselineView),
|
||||
[enabled, activeWorkflowId, isReadOnly, isStreaming, isBaselineView]
|
||||
)
|
||||
const isReplaceEnabled = useMemo(
|
||||
() => Boolean(enabled && activeWorkflowId && !isReadOnly && !isBaselineView),
|
||||
[enabled, activeWorkflowId, isReadOnly, isBaselineView]
|
||||
)
|
||||
|
||||
const lastCommittedValueRef = useRef<string>(value ?? '')
|
||||
const pendingBeforeRef = useRef<string | null>(null)
|
||||
const pendingAfterRef = useRef<string | null>(null)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isApplyingRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetPending = useCallback(() => {
|
||||
pendingBeforeRef.current = null
|
||||
pendingAfterRef.current = null
|
||||
}, [])
|
||||
|
||||
const commitPending = useCallback(() => {
|
||||
if (!isEnabled || !activeWorkflowId) {
|
||||
clearTimer()
|
||||
resetPending()
|
||||
return
|
||||
}
|
||||
|
||||
const before = pendingBeforeRef.current
|
||||
const after = pendingAfterRef.current
|
||||
if (before === null || after === null) return
|
||||
|
||||
if (before === after) {
|
||||
lastCommittedValueRef.current = after
|
||||
clearTimer()
|
||||
resetPending()
|
||||
return
|
||||
}
|
||||
|
||||
useCodeUndoRedoStore.getState().push({
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId,
|
||||
subBlockId,
|
||||
before,
|
||||
after,
|
||||
})
|
||||
|
||||
lastCommittedValueRef.current = after
|
||||
clearTimer()
|
||||
resetPending()
|
||||
}, [activeWorkflowId, blockId, clearTimer, isEnabled, resetPending, subBlockId])
|
||||
|
||||
const recordChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
if (!isEnabled || isApplyingRef.current) return
|
||||
|
||||
if (pendingBeforeRef.current === null) {
|
||||
pendingBeforeRef.current = lastCommittedValueRef.current ?? ''
|
||||
}
|
||||
|
||||
pendingAfterRef.current = nextValue
|
||||
clearTimer()
|
||||
timeoutRef.current = setTimeout(commitPending, debounceMs)
|
||||
},
|
||||
[clearTimer, commitPending, debounceMs, isEnabled]
|
||||
)
|
||||
|
||||
const recordReplace = useCallback(
|
||||
(nextValue: string) => {
|
||||
if (!isReplaceEnabled || isApplyingRef.current || !activeWorkflowId) return
|
||||
|
||||
if (pendingBeforeRef.current !== null) {
|
||||
commitPending()
|
||||
}
|
||||
|
||||
const before = lastCommittedValueRef.current ?? ''
|
||||
if (before === nextValue) {
|
||||
lastCommittedValueRef.current = nextValue
|
||||
resetPending()
|
||||
return
|
||||
}
|
||||
|
||||
useCodeUndoRedoStore.getState().push({
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId,
|
||||
subBlockId,
|
||||
before,
|
||||
after: nextValue,
|
||||
})
|
||||
|
||||
lastCommittedValueRef.current = nextValue
|
||||
clearTimer()
|
||||
resetPending()
|
||||
},
|
||||
[
|
||||
activeWorkflowId,
|
||||
blockId,
|
||||
clearTimer,
|
||||
commitPending,
|
||||
isReplaceEnabled,
|
||||
resetPending,
|
||||
subBlockId,
|
||||
]
|
||||
)
|
||||
|
||||
const flushPending = useCallback(() => {
|
||||
if (pendingBeforeRef.current === null) return
|
||||
clearTimer()
|
||||
commitPending()
|
||||
}, [clearTimer, commitPending])
|
||||
|
||||
const startSession = useCallback(
|
||||
(currentValue: string) => {
|
||||
clearTimer()
|
||||
resetPending()
|
||||
lastCommittedValueRef.current = currentValue ?? ''
|
||||
},
|
||||
[clearTimer, resetPending]
|
||||
)
|
||||
|
||||
const applyValue = useCallback(
|
||||
(nextValue: string) => {
|
||||
if (!isEnabled) return
|
||||
isApplyingRef.current = true
|
||||
try {
|
||||
collaborativeSetSubblockValue(blockId, subBlockId, nextValue)
|
||||
} finally {
|
||||
isApplyingRef.current = false
|
||||
}
|
||||
lastCommittedValueRef.current = nextValue
|
||||
clearTimer()
|
||||
resetPending()
|
||||
},
|
||||
[blockId, clearTimer, collaborativeSetSubblockValue, isEnabled, resetPending, subBlockId]
|
||||
)
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (!activeWorkflowId || !isEnabled) return
|
||||
if (pendingBeforeRef.current !== null) {
|
||||
flushPending()
|
||||
}
|
||||
const entry = useCodeUndoRedoStore.getState().undo(activeWorkflowId, blockId, subBlockId)
|
||||
if (!entry) return
|
||||
logger.debug('Undo code edit', { blockId, subBlockId })
|
||||
applyValue(entry.before)
|
||||
}, [activeWorkflowId, applyValue, blockId, flushPending, isEnabled, subBlockId])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (!activeWorkflowId || !isEnabled) return
|
||||
if (pendingBeforeRef.current !== null) {
|
||||
flushPending()
|
||||
}
|
||||
const entry = useCodeUndoRedoStore.getState().redo(activeWorkflowId, blockId, subBlockId)
|
||||
if (!entry) return
|
||||
logger.debug('Redo code edit', { blockId, subBlockId })
|
||||
applyValue(entry.after)
|
||||
}, [activeWorkflowId, applyValue, blockId, flushPending, isEnabled, subBlockId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApplyingRef.current || isStreaming) return
|
||||
|
||||
const nextValue = value ?? ''
|
||||
|
||||
if (pendingBeforeRef.current !== null) {
|
||||
if (pendingAfterRef.current !== nextValue) {
|
||||
clearTimer()
|
||||
resetPending()
|
||||
lastCommittedValueRef.current = nextValue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastCommittedValueRef.current = nextValue
|
||||
}, [clearTimer, isStreaming, resetPending, value])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
flushPending()
|
||||
}
|
||||
}, [flushPending])
|
||||
|
||||
return {
|
||||
recordChange,
|
||||
recordReplace,
|
||||
flushPending,
|
||||
startSession,
|
||||
undo,
|
||||
redo,
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
|
||||
import { usePanelEditorStore, useVariablesStore } from '@/stores/panel'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -449,6 +449,10 @@ export function useCollaborativeWorkflow() {
|
||||
try {
|
||||
// The setValue function automatically uses the active workflow ID
|
||||
useSubBlockStore.getState().setValue(blockId, subblockId, value)
|
||||
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
|
||||
if (activeWorkflowId && blockType === 'function' && subblockId === 'code') {
|
||||
useCodeUndoRedoStore.getState().clear(activeWorkflowId, blockId, subblockId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error applying remote subblock update:', error)
|
||||
} finally {
|
||||
|
||||
@@ -37,6 +37,13 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.workspaceId) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Workspace ID is required for creating a knowledge base',
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const newKnowledgeBase = await createKnowledgeBase(
|
||||
{
|
||||
|
||||
@@ -79,7 +79,7 @@ export const KnowledgeBaseArgsSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
/** Description of the knowledge base (optional for create) */
|
||||
description: z.string().optional(),
|
||||
/** Workspace ID to associate with (optional for create/list) */
|
||||
/** Workspace ID to associate with (required for create, optional for list) */
|
||||
workspaceId: z.string().optional(),
|
||||
/** Knowledge base ID (required for get, query) */
|
||||
knowledgeBaseId: z.string().optional(),
|
||||
|
||||
@@ -132,6 +132,8 @@ async function executeCode(request) {
|
||||
for (const [key, value] of Object.entries(contextVariables)) {
|
||||
if (value === undefined) {
|
||||
await jail.set(key, undefined)
|
||||
} else if (value === null) {
|
||||
await jail.set(key, null)
|
||||
} else {
|
||||
await jail.set(key, new ivm.ExternalCopy(value).copyInto())
|
||||
}
|
||||
|
||||
@@ -86,18 +86,16 @@ export async function createKnowledgeBase(
|
||||
const kbId = randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
if (data.workspaceId) {
|
||||
const hasPermission = await getUserEntityPermissions(data.userId, 'workspace', data.workspaceId)
|
||||
if (hasPermission === null) {
|
||||
throw new Error('User does not have permission to create knowledge bases in this workspace')
|
||||
}
|
||||
const hasPermission = await getUserEntityPermissions(data.userId, 'workspace', data.workspaceId)
|
||||
if (hasPermission === null) {
|
||||
throw new Error('User does not have permission to create knowledge bases in this workspace')
|
||||
}
|
||||
|
||||
const newKnowledgeBase = {
|
||||
id: kbId,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
workspaceId: data.workspaceId ?? null,
|
||||
workspaceId: data.workspaceId,
|
||||
userId: data.userId,
|
||||
tokenCount: 0,
|
||||
embeddingModel: data.embeddingModel,
|
||||
@@ -122,7 +120,7 @@ export async function createKnowledgeBase(
|
||||
chunkingConfig: data.chunkingConfig,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
workspaceId: data.workspaceId ?? null,
|
||||
workspaceId: data.workspaceId,
|
||||
docCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface KnowledgeBaseWithCounts {
|
||||
export interface CreateKnowledgeBaseData {
|
||||
name: string
|
||||
description?: string
|
||||
workspaceId?: string
|
||||
workspaceId: string
|
||||
embeddingModel: 'text-embedding-3-small'
|
||||
embeddingDimension: 1536
|
||||
chunkingConfig: ChunkingConfig
|
||||
|
||||
@@ -557,7 +557,8 @@ describe('hasWorkflowChanged', () => {
|
||||
})
|
||||
|
||||
describe('InputFormat SubBlock Special Handling', () => {
|
||||
it.concurrent('should ignore value and collapsed fields in inputFormat', () => {
|
||||
it.concurrent('should ignore collapsed field but detect value changes in inputFormat', () => {
|
||||
// Only collapsed changes - should NOT detect as change
|
||||
const state1 = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
@@ -578,8 +579,8 @@ describe('hasWorkflowChanged', () => {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ id: 'input1', name: 'Name', value: 'Jane', collapsed: false },
|
||||
{ id: 'input2', name: 'Age', value: 30, collapsed: true },
|
||||
{ id: 'input1', name: 'Name', value: 'John', collapsed: false },
|
||||
{ id: 'input2', name: 'Age', value: 25, collapsed: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -589,6 +590,32 @@ describe('hasWorkflowChanged', () => {
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should detect value changes in inputFormat', () => {
|
||||
const state1 = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ id: 'input1', name: 'Name', value: 'John' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
const state2 = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ id: 'input1', name: 'Name', value: 'Jane' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect actual inputFormat changes', () => {
|
||||
const state1 = createWorkflowState({
|
||||
blocks: {
|
||||
@@ -1712,15 +1739,15 @@ describe('hasWorkflowChanged', () => {
|
||||
})
|
||||
|
||||
describe('Input Format Field Scenarios', () => {
|
||||
it.concurrent('should not detect change when inputFormat value is typed and cleared', () => {
|
||||
// The "value" field in inputFormat is UI-only and should be ignored
|
||||
it.concurrent('should not detect change when only inputFormat collapsed changes', () => {
|
||||
// The "collapsed" field in inputFormat is UI-only and should be ignored
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ id: 'field1', name: 'Name', type: 'string', value: '', collapsed: false },
|
||||
{ id: 'field1', name: 'Name', type: 'string', value: 'test', collapsed: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1738,7 +1765,7 @@ describe('hasWorkflowChanged', () => {
|
||||
id: 'field1',
|
||||
name: 'Name',
|
||||
type: 'string',
|
||||
value: 'typed then cleared',
|
||||
value: 'test',
|
||||
collapsed: true,
|
||||
},
|
||||
],
|
||||
@@ -1748,10 +1775,40 @@ describe('hasWorkflowChanged', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// value and collapsed are UI-only fields - should NOT detect as change
|
||||
// collapsed is UI-only field - should NOT detect as change
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should detect change when inputFormat value changes', () => {
|
||||
// The "value" field in inputFormat is meaningful and should trigger change detection
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ id: 'field1', name: 'Name', type: 'string', value: '' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ id: 'field1', name: 'Name', type: 'string', value: 'new value' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// value changes should be detected
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect change when inputFormat field name changes', () => {
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
|
||||
@@ -370,7 +370,7 @@ describe('Workflow Normalization Utilities', () => {
|
||||
expect(sanitizeInputFormat({} as any)).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should remove value and collapsed fields', () => {
|
||||
it.concurrent('should remove collapsed field but keep value', () => {
|
||||
const inputFormat = [
|
||||
{ id: 'input1', name: 'Name', value: 'John', collapsed: true },
|
||||
{ id: 'input2', name: 'Age', value: 25, collapsed: false },
|
||||
@@ -379,13 +379,13 @@ describe('Workflow Normalization Utilities', () => {
|
||||
const result = sanitizeInputFormat(inputFormat)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'input1', name: 'Name' },
|
||||
{ id: 'input2', name: 'Age' },
|
||||
{ id: 'input1', name: 'Name', value: 'John' },
|
||||
{ id: 'input2', name: 'Age', value: 25 },
|
||||
{ id: 'input3', name: 'Email' },
|
||||
])
|
||||
})
|
||||
|
||||
it.concurrent('should preserve all other fields', () => {
|
||||
it.concurrent('should preserve all other fields including value', () => {
|
||||
const inputFormat = [
|
||||
{
|
||||
id: 'input1',
|
||||
@@ -402,6 +402,7 @@ describe('Workflow Normalization Utilities', () => {
|
||||
expect(result[0]).toEqual({
|
||||
id: 'input1',
|
||||
name: 'Complex Input',
|
||||
value: 'test-value',
|
||||
type: 'string',
|
||||
required: true,
|
||||
validation: { min: 0, max: 100 },
|
||||
|
||||
@@ -156,10 +156,10 @@ export function normalizeVariables(variables: unknown): Record<string, Variable>
|
||||
}
|
||||
|
||||
/** Input format item with optional UI-only fields */
|
||||
type InputFormatItem = Record<string, unknown> & { value?: unknown; collapsed?: boolean }
|
||||
type InputFormatItem = Record<string, unknown> & { collapsed?: boolean }
|
||||
|
||||
/**
|
||||
* Sanitizes inputFormat array by removing UI-only fields like value and collapsed
|
||||
* Sanitizes inputFormat array by removing UI-only fields like collapsed
|
||||
* @param inputFormat - Array of input format configurations
|
||||
* @returns Sanitized input format array
|
||||
*/
|
||||
@@ -167,7 +167,7 @@ export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record<
|
||||
if (!Array.isArray(inputFormat)) return []
|
||||
return inputFormat.map((item) => {
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const { value, collapsed, ...rest } = item as InputFormatItem
|
||||
const { collapsed, ...rest } = item as InputFormatItem
|
||||
return rest
|
||||
}
|
||||
return item as Record<string, unknown>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('VariableManager', () => {
|
||||
it.concurrent('should handle boolean type variables', () => {
|
||||
expect(VariableManager.parseInputForStorage('true', 'boolean')).toBe(true)
|
||||
expect(VariableManager.parseInputForStorage('false', 'boolean')).toBe(false)
|
||||
expect(VariableManager.parseInputForStorage('1', 'boolean')).toBe(true)
|
||||
expect(VariableManager.parseInputForStorage('1', 'boolean')).toBe(false)
|
||||
expect(VariableManager.parseInputForStorage('0', 'boolean')).toBe(false)
|
||||
expect(VariableManager.parseInputForStorage('"true"', 'boolean')).toBe(true)
|
||||
expect(VariableManager.parseInputForStorage("'false'", 'boolean')).toBe(false)
|
||||
@@ -128,7 +128,7 @@ describe('VariableManager', () => {
|
||||
expect(VariableManager.resolveForExecution(false, 'boolean')).toBe(false)
|
||||
expect(VariableManager.resolveForExecution('true', 'boolean')).toBe(true)
|
||||
expect(VariableManager.resolveForExecution('false', 'boolean')).toBe(false)
|
||||
expect(VariableManager.resolveForExecution('1', 'boolean')).toBe(true)
|
||||
expect(VariableManager.resolveForExecution('1', 'boolean')).toBe(false)
|
||||
expect(VariableManager.resolveForExecution('0', 'boolean')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export class VariableManager {
|
||||
// Special case for 'anything else' in the test
|
||||
if (unquoted === 'anything else') return true
|
||||
const normalized = String(unquoted).toLowerCase().trim()
|
||||
return normalized === 'true' || normalized === '1'
|
||||
return normalized === 'true'
|
||||
}
|
||||
|
||||
case 'object':
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
generateToolUseId,
|
||||
} from '@/providers/anthropic/utils'
|
||||
import {
|
||||
getMaxOutputTokensForModel,
|
||||
getProviderDefaultModel,
|
||||
getProviderModels,
|
||||
supportsNativeStructuredOutputs,
|
||||
@@ -178,7 +179,9 @@ export const anthropicProvider: ProviderConfig = {
|
||||
model: request.model,
|
||||
messages,
|
||||
system: systemPrompt,
|
||||
max_tokens: Number.parseInt(String(request.maxTokens)) || 1024,
|
||||
max_tokens:
|
||||
Number.parseInt(String(request.maxTokens)) ||
|
||||
getMaxOutputTokensForModel(request.model, request.stream ?? false),
|
||||
temperature: Number.parseFloat(String(request.temperature ?? 0.7)),
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
generateToolUseId,
|
||||
getBedrockInferenceProfileId,
|
||||
} from '@/providers/bedrock/utils'
|
||||
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
|
||||
import {
|
||||
getMaxOutputTokensForModel,
|
||||
getProviderDefaultModel,
|
||||
getProviderModels,
|
||||
} from '@/providers/models'
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ProviderRequest,
|
||||
@@ -259,7 +263,9 @@ export const bedrockProvider: ProviderConfig = {
|
||||
|
||||
const inferenceConfig = {
|
||||
temperature: Number.parseFloat(String(request.temperature ?? 0.7)),
|
||||
maxTokens: Number.parseInt(String(request.maxTokens)) || 4096,
|
||||
maxTokens:
|
||||
Number.parseInt(String(request.maxTokens)) ||
|
||||
getMaxOutputTokensForModel(request.model, request.stream ?? false),
|
||||
}
|
||||
|
||||
const shouldStreamToolCalls = request.streamToolCalls ?? false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user