mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
2 Commits
v0.5.105
...
waleedlati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136d5b5679 | ||
|
|
550e29d1af |
@@ -69,9 +69,7 @@ Read records from a ServiceNow table
|
||||
| `number` | string | No | Record number \(e.g., INC0010001\) |
|
||||
| `query` | string | No | Encoded query string \(e.g., "active=true^priority=1"\) |
|
||||
| `limit` | number | No | Maximum number of records to return \(e.g., 10, 50, 100\) |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(e.g., 0, 10, 20\) |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., sys_id,number,short_description,state\) |
|
||||
| `displayValue` | string | No | Return display values for reference fields: "true" \(display only\), "false" \(sys_id only\), or "all" \(both\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Slack
|
||||
description: Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack
|
||||
description: Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -39,7 +39,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai](
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
|
||||
|
||||
|
||||
@@ -799,128 +799,4 @@ Add an emoji reaction to a Slack message
|
||||
| ↳ `timestamp` | string | Message timestamp |
|
||||
| ↳ `reaction` | string | Emoji reaction name |
|
||||
|
||||
### `slack_remove_reaction`
|
||||
|
||||
Remove an emoji reaction from a Slack message
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID where the message was posted \(e.g., C1234567890\) |
|
||||
| `timestamp` | string | Yes | Timestamp of the message to remove reaction from \(e.g., 1405894322.002768\) |
|
||||
| `name` | string | Yes | Name of the emoji reaction to remove \(without colons, e.g., thumbsup, heart, eyes\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Success message |
|
||||
| `metadata` | object | Reaction metadata |
|
||||
| ↳ `channel` | string | Channel ID |
|
||||
| ↳ `timestamp` | string | Message timestamp |
|
||||
| ↳ `reaction` | string | Emoji reaction name |
|
||||
|
||||
### `slack_get_channel_info`
|
||||
|
||||
Get detailed information about a Slack channel by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID to get information about \(e.g., C1234567890\) |
|
||||
| `includeNumMembers` | boolean | No | Whether to include the member count in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `channelInfo` | object | Detailed channel information |
|
||||
| ↳ `id` | string | Channel ID \(e.g., C1234567890\) |
|
||||
| ↳ `name` | string | Channel name without # prefix |
|
||||
| ↳ `is_channel` | boolean | Whether this is a channel |
|
||||
| ↳ `is_private` | boolean | Whether channel is private |
|
||||
| ↳ `is_archived` | boolean | Whether channel is archived |
|
||||
| ↳ `is_general` | boolean | Whether this is the general channel |
|
||||
| ↳ `is_member` | boolean | Whether the bot/user is a member |
|
||||
| ↳ `is_shared` | boolean | Whether channel is shared across workspaces |
|
||||
| ↳ `is_ext_shared` | boolean | Whether channel is externally shared |
|
||||
| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared |
|
||||
| ↳ `num_members` | number | Number of members in the channel |
|
||||
| ↳ `topic` | string | Channel topic |
|
||||
| ↳ `purpose` | string | Channel purpose/description |
|
||||
| ↳ `created` | number | Unix timestamp when channel was created |
|
||||
| ↳ `creator` | string | User ID of channel creator |
|
||||
| ↳ `updated` | number | Unix timestamp of last update |
|
||||
|
||||
### `slack_get_user_presence`
|
||||
|
||||
Check whether a Slack user is currently active or away
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `userId` | string | Yes | User ID to check presence for \(e.g., U1234567890\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `presence` | string | User presence status: "active" or "away" |
|
||||
| `online` | boolean | Whether user has an active client connection \(only available when checking own presence\) |
|
||||
| `autoAway` | boolean | Whether user was automatically set to away due to inactivity \(only available when checking own presence\) |
|
||||
| `manualAway` | boolean | Whether user manually set themselves as away \(only available when checking own presence\) |
|
||||
| `connectionCount` | number | Total number of active connections for the user \(only available when checking own presence\) |
|
||||
| `lastActivity` | number | Unix timestamp of last detected activity \(only available when checking own presence\) |
|
||||
|
||||
### `slack_edit_canvas`
|
||||
|
||||
Edit an existing Slack canvas by inserting, replacing, or deleting content
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `canvasId` | string | Yes | Canvas ID to edit \(e.g., F1234ABCD\) |
|
||||
| `operation` | string | Yes | Edit operation: insert_at_start, insert_at_end, insert_after, insert_before, replace, delete, or rename |
|
||||
| `content` | string | No | Markdown content for the operation \(required for insert/replace operations\) |
|
||||
| `sectionId` | string | No | Section ID to target \(required for insert_after, insert_before, replace, and delete\) |
|
||||
| `title` | string | No | New title for the canvas \(only used with rename operation\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Success message |
|
||||
|
||||
### `slack_create_channel_canvas`
|
||||
|
||||
Create a canvas pinned to a Slack channel as its resource hub
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID to create the canvas in \(e.g., C1234567890\) |
|
||||
| `title` | string | No | Title for the channel canvas |
|
||||
| `content` | string | No | Canvas content in markdown format |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `canvas_id` | string | ID of the created channel canvas |
|
||||
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const SlackRemoveReactionSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
channel: z.string().min(1, 'Channel is required'),
|
||||
timestamp: z.string().min(1, 'Message timestamp is required'),
|
||||
name: z.string().min(1, 'Emoji name is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = SlackRemoveReactionSchema.parse(body)
|
||||
|
||||
const slackResponse = await fetch('https://slack.com/api/reactions.remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
name: validatedData.name,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to remove reaction',
|
||||
},
|
||||
{ status: slackResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
content: `Successfully removed :${validatedData.name}: reaction`,
|
||||
metadata: {
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
reaction: validatedData.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
throw new Error(`Failed to download audio from URL: ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,6 @@ async function fetchDocumentBytes(url: string): Promise<{ bytes: string; content
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
throw new Error(`Failed to fetch document: ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
logger.error(`Failed to generate TTS: ${response.status} ${response.statusText}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to generate TTS: ${response.status} ${response.statusText}` },
|
||||
|
||||
@@ -184,7 +184,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch image for Gemini' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -964,7 +964,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
logger.error(`[${requestId}] Error streaming block content:`, error)
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel().catch(() => {})
|
||||
reader.releaseLock()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export const ActionBar = memo(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-top-[46px] pointer-events-auto absolute right-0',
|
||||
'-top-[46px] absolute right-0',
|
||||
'flex flex-row items-center',
|
||||
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
||||
'gap-[5px] rounded-[10px] p-[5px]',
|
||||
|
||||
@@ -501,6 +501,17 @@ export function Chat() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExecuting && isStreaming) {
|
||||
const lastMessage = workflowMessages[workflowMessages.length - 1]
|
||||
if (lastMessage?.isStreaming) {
|
||||
streamReaderRef.current?.cancel()
|
||||
streamReaderRef.current = null
|
||||
finalizeMessageStream(lastMessage.id)
|
||||
}
|
||||
}
|
||||
}, [isExecuting, isStreaming, workflowMessages, finalizeMessageStream])
|
||||
|
||||
const handleStopStreaming = useCallback(() => {
|
||||
streamReaderRef.current?.cancel()
|
||||
streamReaderRef.current = null
|
||||
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
|
||||
import {
|
||||
restoreCursorAfterInsertion,
|
||||
sanitizeForParsing,
|
||||
validateJavaScript,
|
||||
validatePython,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
|
||||
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
||||
@@ -166,7 +171,7 @@ interface CodeProps {
|
||||
defaultCollapsed?: boolean
|
||||
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
|
||||
showCopyButton?: boolean
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
onValidationChange?: (isValid: boolean, errorMessage?: string | null) => void
|
||||
wandConfig: {
|
||||
enabled: boolean
|
||||
prompt: string
|
||||
@@ -250,6 +255,18 @@ export const Code = memo(function Code({
|
||||
}
|
||||
}, [shouldValidateJson, trimmedCode])
|
||||
|
||||
const syntaxError = useMemo(() => {
|
||||
if (effectiveLanguage === 'json' || !trimmedCode) return null
|
||||
const sanitized = sanitizeForParsing(trimmedCode)
|
||||
if (effectiveLanguage === 'javascript') {
|
||||
return validateJavaScript(sanitized)
|
||||
}
|
||||
if (effectiveLanguage === 'python') {
|
||||
return validatePython(sanitized)
|
||||
}
|
||||
return null
|
||||
}, [effectiveLanguage, trimmedCode])
|
||||
|
||||
const gutterWidthPx = useMemo(() => {
|
||||
const lineCount = code.split('\n').length
|
||||
return calculateGutterWidth(lineCount)
|
||||
@@ -341,19 +358,21 @@ export const Code = memo(function Code({
|
||||
useEffect(() => {
|
||||
if (!onValidationChange) return
|
||||
|
||||
const isValid = !shouldValidateJson || isValidJson
|
||||
const isValid = (!shouldValidateJson || isValidJson) && !syntaxError
|
||||
|
||||
if (isValid) {
|
||||
onValidationChange(true)
|
||||
onValidationChange(true, null)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = !isValidJson ? 'Invalid JSON' : syntaxError
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
onValidationChange(false)
|
||||
onValidationChange(false, errorMessage)
|
||||
}, 150)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [isValidJson, onValidationChange, shouldValidateJson])
|
||||
}, [isValidJson, syntaxError, onValidationChange, shouldValidateJson])
|
||||
|
||||
useEffect(() => {
|
||||
handleStreamStartRef.current = () => {
|
||||
|
||||
@@ -189,7 +189,7 @@ const getPreviewValue = (
|
||||
* Renders the label with optional validation and description tooltips.
|
||||
*
|
||||
* @param config - The sub-block configuration defining the label content
|
||||
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
||||
* @param codeValidation - Validation state for code blocks (valid flag + optional error message)
|
||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||
* @param wandState - State and handlers for the inline AI generate feature
|
||||
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
|
||||
@@ -200,7 +200,7 @@ const getPreviewValue = (
|
||||
*/
|
||||
const renderLabel = (
|
||||
config: SubBlockConfig,
|
||||
isValidJson: boolean,
|
||||
codeValidation: { isValid: boolean; errorMessage: string | null },
|
||||
subBlockValues?: Record<string, any>,
|
||||
wandState?: {
|
||||
isSearchActive: boolean
|
||||
@@ -250,21 +250,18 @@ const renderLabel = (
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{labelSuffix}
|
||||
{config.type === 'code' &&
|
||||
config.language === 'json' &&
|
||||
!isValidJson &&
|
||||
!wandState?.isStreaming && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>
|
||||
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Invalid JSON</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{config.type === 'code' && !codeValidation.isValid && !wandState?.isStreaming && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>
|
||||
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>{codeValidation.errorMessage ?? 'Syntax error'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</Label>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
|
||||
{showCopy && (
|
||||
@@ -466,7 +463,8 @@ function SubBlockComponent({
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const [isValidJson, setIsValidJson] = useState(true)
|
||||
const [isValidCode, setIsValidCode] = useState(true)
|
||||
const [codeErrorMessage, setCodeErrorMessage] = useState<string | null>(null)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -484,8 +482,9 @@ function SubBlockComponent({
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleValidationChange = (isValid: boolean): void => {
|
||||
setIsValidJson(isValid)
|
||||
const handleValidationChange = (isValid: boolean, errorMessage?: string | null): void => {
|
||||
setIsValidCode(isValid)
|
||||
setCodeErrorMessage(errorMessage ?? null)
|
||||
}
|
||||
|
||||
const isWandEnabled = config.wandConfig?.enabled ?? false
|
||||
@@ -1151,7 +1150,7 @@ function SubBlockComponent({
|
||||
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
|
||||
{renderLabel(
|
||||
config,
|
||||
isValidJson,
|
||||
{ isValid: isValidCode, errorMessage: codeErrorMessage },
|
||||
subBlockValues,
|
||||
{
|
||||
isSearchActive,
|
||||
|
||||
@@ -40,10 +40,6 @@ 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 {
|
||||
isAncestorProtected,
|
||||
isBlockProtected,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockType } from '@/blocks/types'
|
||||
@@ -111,11 +107,12 @@ export function Editor() {
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Check if block is locked (or inside a locked ancestor) and compute edit permission
|
||||
// Check if block is locked (or inside a locked container) and compute edit permission
|
||||
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false
|
||||
const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false
|
||||
const parentId = currentBlock?.data?.parentId as string | undefined
|
||||
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
|
||||
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
|
||||
const canEditBlock = userPermissions.canEdit && !isLocked
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
@@ -250,7 +247,10 @@ export function Editor() {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return
|
||||
|
||||
if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
|
||||
const isLocked = (block.locked ?? false) || isParentLocked
|
||||
if (!userPermissions.canEdit || isLocked) return
|
||||
|
||||
renamingBlockIdRef.current = blockId
|
||||
setEditedName(block.name || '')
|
||||
@@ -364,11 +364,11 @@ export function Editor() {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked directly, and not locked by an ancestor */}
|
||||
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
|
||||
{isLocked && currentBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
{userPermissions.canAdmin && currentBlock.locked && !isAncestorLocked ? (
|
||||
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
@@ -385,8 +385,8 @@ export function Editor() {
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>
|
||||
{isAncestorLocked
|
||||
? 'Ancestor container is locked'
|
||||
{isParentLocked
|
||||
? 'Parent container is locked'
|
||||
: userPermissions.canAdmin && currentBlock.locked
|
||||
? 'Unlock block'
|
||||
: 'Block is locked'}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeForParsing, validateJavaScript, validatePython } from './utils'
|
||||
|
||||
describe('sanitizeForParsing', () => {
|
||||
it('replaces <Block.output> references with valid identifiers', () => {
|
||||
const result = sanitizeForParsing('const x = <Block.output>')
|
||||
expect(result).not.toContain('<')
|
||||
expect(result).not.toContain('>')
|
||||
expect(result).toContain('__placeholder_')
|
||||
})
|
||||
|
||||
it('replaces {{ENV_VAR}} with valid identifiers', () => {
|
||||
const result = sanitizeForParsing('const url = {{API_URL}}')
|
||||
expect(result).not.toContain('{{')
|
||||
expect(result).not.toContain('}}')
|
||||
expect(result).toContain('__placeholder_')
|
||||
})
|
||||
|
||||
it('replaces nested path references like <Block.output[0].field>', () => {
|
||||
const result = sanitizeForParsing('const x = <Agent.response.choices[0].text>')
|
||||
expect(result).not.toContain('<Agent')
|
||||
})
|
||||
|
||||
it('replaces loop/parallel context references', () => {
|
||||
const result = sanitizeForParsing('const item = <loop.currentItem>')
|
||||
expect(result).not.toContain('<loop')
|
||||
})
|
||||
|
||||
it('replaces variable references', () => {
|
||||
const result = sanitizeForParsing('const v = <variable.myVar>')
|
||||
expect(result).not.toContain('<variable')
|
||||
})
|
||||
|
||||
it('handles multiple references in one string', () => {
|
||||
const code = 'const a = <Block1.out>; const b = {{SECRET}}; const c = <Block2.value>'
|
||||
const result = sanitizeForParsing(code)
|
||||
expect(result).not.toContain('<Block1')
|
||||
expect(result).not.toContain('{{SECRET}}')
|
||||
expect(result).not.toContain('<Block2')
|
||||
expect(result.match(/__placeholder_/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
it('does not replace regular JS comparison operators', () => {
|
||||
const code = 'if (a < b && c > d) {}'
|
||||
const result = sanitizeForParsing(code)
|
||||
expect(result).toBe(code)
|
||||
})
|
||||
|
||||
it('does not replace HTML tags that are not references', () => {
|
||||
const code = 'const html = "<div>hello</div>"'
|
||||
const result = sanitizeForParsing(code)
|
||||
expect(result).toBe(code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateJavaScript', () => {
|
||||
it('returns null for valid JavaScript', () => {
|
||||
expect(validateJavaScript('const x = 1')).toBeNull()
|
||||
expect(validateJavaScript('function foo() { return 42 }')).toBeNull()
|
||||
expect(validateJavaScript('const arr = [1, 2, 3].map(x => x * 2)')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid async/await code', () => {
|
||||
expect(validateJavaScript('async function foo() { await bar() }')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for bare return statements (function block wraps in async fn)', () => {
|
||||
expect(validateJavaScript('return 42')).toBeNull()
|
||||
expect(validateJavaScript(sanitizeForParsing('return <Block.output>'))).toBeNull()
|
||||
expect(validateJavaScript('const x = 1\nreturn x')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for await at top level (wrapped in async fn)', () => {
|
||||
expect(validateJavaScript('const res = await fetch("url")')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid ES module syntax', () => {
|
||||
expect(validateJavaScript('import { foo } from "bar"')).toBeNull()
|
||||
expect(validateJavaScript('export default function() {}')).toBeNull()
|
||||
})
|
||||
|
||||
it('detects missing closing brace', () => {
|
||||
const result = validateJavaScript('function foo() {')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Syntax error')
|
||||
})
|
||||
|
||||
it('detects missing closing paren', () => {
|
||||
const result = validateJavaScript('console.log("hello"')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Syntax error')
|
||||
})
|
||||
|
||||
it('detects unexpected token', () => {
|
||||
const result = validateJavaScript('const = 5')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Syntax error')
|
||||
})
|
||||
|
||||
it('includes adjusted line and column in error message', () => {
|
||||
const result = validateJavaScript('const x = 1\nconst = 5')
|
||||
expect(result).toMatch(/line 2/)
|
||||
expect(result).toMatch(/col \d+/)
|
||||
})
|
||||
|
||||
it('returns null for empty code', () => {
|
||||
expect(validateJavaScript('')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not error on sanitized references', () => {
|
||||
const code = sanitizeForParsing('const x = <Block.output> + {{ENV_VAR}}')
|
||||
expect(validateJavaScript(code)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePython', () => {
|
||||
it('returns null for valid Python', () => {
|
||||
expect(validatePython('x = 1')).toBeNull()
|
||||
expect(validatePython('def foo():\n return 42')).toBeNull()
|
||||
expect(validatePython('arr = [1, 2, 3]')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for Python with comments', () => {
|
||||
expect(validatePython('x = 1 # this is a comment')).toBeNull()
|
||||
expect(validatePython('# full line comment\nx = 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for Python with strings containing brackets', () => {
|
||||
expect(validatePython('x = "hello (world)"')).toBeNull()
|
||||
expect(validatePython("x = 'brackets [here] {too}'")).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for triple-quoted strings', () => {
|
||||
expect(validatePython('x = """hello\nworld"""')).toBeNull()
|
||||
expect(validatePython("x = '''multi\nline\nstring'''")).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for triple-quoted strings with brackets', () => {
|
||||
expect(validatePython('x = """has { and ( inside"""')).toBeNull()
|
||||
})
|
||||
|
||||
it('detects unmatched opening paren', () => {
|
||||
const result = validatePython('foo(1, 2')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain("'('")
|
||||
})
|
||||
|
||||
it('detects unmatched closing paren', () => {
|
||||
const result = validatePython('foo)')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain("')'")
|
||||
})
|
||||
|
||||
it('detects unmatched bracket', () => {
|
||||
const result = validatePython('arr = [1, 2')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain("'['")
|
||||
})
|
||||
|
||||
it('detects unterminated string', () => {
|
||||
const result = validatePython('x = "hello')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Unterminated string')
|
||||
})
|
||||
|
||||
it('detects unterminated triple-quoted string', () => {
|
||||
const result = validatePython('x = """hello')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Unterminated triple-quoted string')
|
||||
})
|
||||
|
||||
it('includes line number in error', () => {
|
||||
const result = validatePython('x = 1\ny = (2')
|
||||
expect(result).toMatch(/line 2/)
|
||||
})
|
||||
|
||||
it('handles escaped quotes in strings', () => {
|
||||
expect(validatePython('x = "hello \\"world\\""')).toBeNull()
|
||||
expect(validatePython("x = 'it\\'s fine'")).toBeNull()
|
||||
})
|
||||
|
||||
it('handles brackets inside comments', () => {
|
||||
expect(validatePython('x = 1 # unmatched ( here')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty code', () => {
|
||||
expect(validatePython('')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not error on sanitized references', () => {
|
||||
const code = sanitizeForParsing('x = <Block.output> + {{ENV_VAR}}')
|
||||
expect(validatePython(code)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,17 @@
|
||||
import { parse } from 'acorn'
|
||||
|
||||
/**
|
||||
* Matches Sim block references: `<word.path>`, `<word.path[0].nested>`, `<loop.index>`, etc.
|
||||
* Must contain a dot (.) to distinguish from HTML tags or comparison operators.
|
||||
*/
|
||||
const REFERENCE_PATTERN = /<[a-zA-Z]\w*(?:\.\w+(?:\[\d+\])?)+>/g
|
||||
|
||||
/**
|
||||
* Matches Sim env-var placeholders: `{{WORD}}`, `{{MY_VAR}}`.
|
||||
* Only allows word characters (no spaces, special chars).
|
||||
*/
|
||||
const ENV_VAR_PATTERN = /\{\{\w+\}\}/g
|
||||
|
||||
/**
|
||||
* Restores the cursor position in a textarea after a dropdown insertion.
|
||||
* Schedules a macrotask (via setTimeout) that runs after React's controlled-component commit
|
||||
@@ -18,3 +32,132 @@ export function restoreCursorAfterInsertion(
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `<Block.output>` references and `{{ENV_VAR}}` placeholders with
|
||||
* valid JS/Python identifiers so the code can be parsed without false errors.
|
||||
*/
|
||||
export function sanitizeForParsing(code: string): string {
|
||||
let counter = 0
|
||||
let sanitized = code.replace(ENV_VAR_PATTERN, () => `__placeholder_${counter++}__`)
|
||||
sanitized = sanitized.replace(REFERENCE_PATTERN, () => `__placeholder_${counter++}__`)
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates JavaScript code for syntax errors using acorn.
|
||||
*
|
||||
* Tries two parse strategies to match the Function block's runtime behavior:
|
||||
* 1. As a module (`import`/`export` are valid at top level)
|
||||
* 2. Wrapped in `async () => { ... }` (bare `return`/`await` are valid)
|
||||
*
|
||||
* Only reports an error if both strategies fail, using the wrapped error
|
||||
* since that's the primary execution context.
|
||||
*
|
||||
* @returns Error message string, or null if valid.
|
||||
*/
|
||||
export function validateJavaScript(code: string): string | null {
|
||||
try {
|
||||
parse(code, { ecmaVersion: 'latest', sourceType: 'module' })
|
||||
return null
|
||||
} catch {
|
||||
// Module parse failed — try as function body (allows bare return/await)
|
||||
}
|
||||
|
||||
const wrapped = `(async () => {\n${code}\n})()`
|
||||
try {
|
||||
parse(wrapped, { ecmaVersion: 'latest', sourceType: 'script' })
|
||||
return null
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof SyntaxError) {
|
||||
const msg = e.message
|
||||
const match = msg.match(/\((\d+):(\d+)\)/)
|
||||
if (match) {
|
||||
const adjustedLine = Number(match[1]) - 1
|
||||
if (adjustedLine < 1) return null
|
||||
return `Syntax error at line ${adjustedLine}, col ${match[2]}: ${msg.replace(/\s*\(\d+:\d+\)/, '')}`
|
||||
}
|
||||
return `Syntax error: ${msg}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Python code for common syntax errors: unmatched brackets/parens,
|
||||
* unterminated strings (single-line and triple-quoted).
|
||||
* Processes the entire code string as a stream to correctly handle
|
||||
* multiline triple-quoted strings.
|
||||
*
|
||||
* @returns Error message string, or null if no issues detected.
|
||||
*/
|
||||
export function validatePython(code: string): string | null {
|
||||
const stack: { char: string; line: number }[] = []
|
||||
const openers: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
|
||||
const closers = new Set([')', ']', '}'])
|
||||
const openChars = new Set(['(', '[', '{'])
|
||||
|
||||
let line = 1
|
||||
let i = 0
|
||||
|
||||
while (i < code.length) {
|
||||
const ch = code[i]
|
||||
|
||||
if (ch === '\n') {
|
||||
line++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '#') {
|
||||
const newline = code.indexOf('\n', i)
|
||||
i = newline === -1 ? code.length : newline
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
const tripleQuote = ch.repeat(3)
|
||||
if (code.slice(i, i + 3) === tripleQuote) {
|
||||
const startLine = line
|
||||
const endIdx = code.indexOf(tripleQuote, i + 3)
|
||||
if (endIdx === -1) {
|
||||
return `Unterminated triple-quoted string starting at line ${startLine}`
|
||||
}
|
||||
for (let k = i; k < endIdx + 3; k++) {
|
||||
if (code[k] === '\n') line++
|
||||
}
|
||||
i = endIdx + 3
|
||||
continue
|
||||
}
|
||||
|
||||
const startLine = line
|
||||
i++
|
||||
while (i < code.length && code[i] !== ch && code[i] !== '\n') {
|
||||
if (code[i] === '\\') i++
|
||||
i++
|
||||
}
|
||||
if (i >= code.length || code[i] === '\n') {
|
||||
return `Unterminated string at line ${startLine}`
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (openChars.has(ch)) {
|
||||
stack.push({ char: ch, line })
|
||||
} else if (closers.has(ch)) {
|
||||
if (stack.length === 0 || stack[stack.length - 1].char !== openers[ch]) {
|
||||
return `Unmatched '${ch}' at line ${line}`
|
||||
}
|
||||
stack.pop()
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
const unmatched = stack[stack.length - 1]
|
||||
return `Unmatched '${unmatched.char}' opened at line ${unmatched.line}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { Badge } from '@/components/emcn'
|
||||
@@ -28,28 +28,6 @@ export interface SubflowNodeData {
|
||||
executionStatus?: 'success' | 'error' | 'not-executed'
|
||||
}
|
||||
|
||||
const HANDLE_STYLE = {
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Reusable class names for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
/**
|
||||
* Subflow node component for loop and parallel execution containers.
|
||||
* Renders a resizable container with a header displaying the block name and icon,
|
||||
@@ -60,6 +38,7 @@ const getHandleClasses = (position: 'left' | 'right') => {
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -73,6 +52,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
const isLocked = currentBlock?.locked ?? false
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Focus state
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
@@ -104,7 +84,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
}
|
||||
|
||||
return level
|
||||
}, [data?.parentId, getNodes])
|
||||
}, [id, data?.parentId, getNodes])
|
||||
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
@@ -112,6 +92,27 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
|
||||
|
||||
/**
|
||||
* Reusable styles and positioning for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
const getHandleStyle = () => {
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
|
||||
@@ -126,37 +127,46 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
!!runPathStatus
|
||||
|
||||
/**
|
||||
* Compute the ring color for the subflow selection indicator.
|
||||
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
|
||||
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
|
||||
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
|
||||
* Compute the outline color for the subflow ring.
|
||||
* Uses CSS outline instead of box-shadow ring because in ReactFlow v11,
|
||||
* child nodes are DOM children of parent nodes and paint over the parent's
|
||||
* internal ring overlay. Outline renders on the element's own compositing
|
||||
* layer, so it stays visible above nested child nodes.
|
||||
*/
|
||||
const getRingColor = (): string | undefined => {
|
||||
if (!hasRing) return undefined
|
||||
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
|
||||
if (diffStatus === 'new') return 'var(--brand-tertiary-2)'
|
||||
if (diffStatus === 'edited') return 'var(--warning)'
|
||||
if (runPathStatus === 'success') {
|
||||
return executionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
}
|
||||
if (runPathStatus === 'error') return 'var(--text-error)'
|
||||
return undefined
|
||||
}
|
||||
const ringColor = getRingColor()
|
||||
const outlineColor = hasRing
|
||||
? isFocused || isSelected || isPreviewSelected
|
||||
? 'var(--brand-secondary)'
|
||||
: diffStatus === 'new'
|
||||
? 'var(--brand-tertiary-2)'
|
||||
: diffStatus === 'edited'
|
||||
? 'var(--warning)'
|
||||
: runPathStatus === 'success'
|
||||
? executionStatus
|
||||
? 'var(--brand-tertiary-2)'
|
||||
: 'var(--border-success)'
|
||||
: runPathStatus === 'error'
|
||||
? 'var(--text-error)'
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className='group pointer-events-none relative'>
|
||||
<div className='group relative'>
|
||||
<div
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)] transition-block-bg'
|
||||
ref={blockRef}
|
||||
className={cn(
|
||||
'relative select-none rounded-[8px] border border-[var(--border-1)]',
|
||||
'transition-block-bg'
|
||||
)}
|
||||
style={{
|
||||
width: data.width || 500,
|
||||
height: data.height || 300,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
...(ringColor && {
|
||||
boxShadow: `0 0 0 1.75px ${ringColor}`,
|
||||
...(outlineColor && {
|
||||
outline: `1.75px solid ${outlineColor}`,
|
||||
outlineOffset: '-1px',
|
||||
}),
|
||||
}}
|
||||
data-node-id={id}
|
||||
@@ -171,7 +181,9 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
{/* Header Section — only interactive area for dragging */}
|
||||
<div
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
className={cn(
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
@@ -197,17 +209,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Click-catching background — selects this subflow when the body area is clicked.
|
||||
* No event bubbling concern: ReactFlow renders child nodes as viewport-level siblings,
|
||||
* not as DOM children of this component, so child clicks never reach this div.
|
||||
*/}
|
||||
<div
|
||||
className='absolute inset-0 top-[44px] rounded-b-[8px]'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
/>
|
||||
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
@@ -216,9 +217,12 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
)}
|
||||
|
||||
<div
|
||||
className='relative h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
data-dragarea='true'
|
||||
style={{ pointerEvents: 'none' }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Subflow Start */}
|
||||
<div
|
||||
@@ -251,7 +255,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
position={Position.Left}
|
||||
className={getHandleClasses('left')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
...getHandleStyle(),
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
@@ -262,7 +266,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
position={Position.Right}
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
...getHandleStyle(),
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id={endHandleId}
|
||||
|
||||
@@ -527,8 +527,7 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
const { displayName: credentialName } = useCredentialName(
|
||||
credentialSourceId,
|
||||
credentialProviderId,
|
||||
workflowId,
|
||||
workspaceId
|
||||
workflowId
|
||||
)
|
||||
|
||||
const credentialId = dependencyValues.credential
|
||||
|
||||
@@ -1124,7 +1124,9 @@ export function useWorkflowExecution() {
|
||||
{} as typeof workflowBlocks
|
||||
)
|
||||
|
||||
const isExecutingFromChat = overrideTriggerType === 'chat'
|
||||
const isExecutingFromChat =
|
||||
overrideTriggerType === 'chat' ||
|
||||
(workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput)
|
||||
|
||||
logger.info('Executing workflow', {
|
||||
isDiffMode: currentWorkflow.isDiffMode,
|
||||
@@ -1493,13 +1495,8 @@ export function useWorkflowExecution() {
|
||||
: null
|
||||
if (activeWorkflowId && !workflowExecState?.isDebugging) {
|
||||
setExecutionResult(executionResult)
|
||||
// For chat executions, don't set isExecuting=false here — the chat's
|
||||
// client-side stream wrapper still has buffered data to deliver.
|
||||
// The chat's finally block handles cleanup after the stream is fully consumed.
|
||||
if (!isExecutingFromChat) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
||||
}, 1000)
|
||||
@@ -1539,7 +1536,7 @@ export function useWorkflowExecution() {
|
||||
isPreExecutionError,
|
||||
})
|
||||
|
||||
if (activeWorkflowId && !isExecutingFromChat) {
|
||||
if (activeWorkflowId) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
@@ -1565,7 +1562,7 @@ export function useWorkflowExecution() {
|
||||
durationMs: data?.duration,
|
||||
})
|
||||
|
||||
if (activeWorkflowId && !isExecutingFromChat) {
|
||||
if (activeWorkflowId) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { isAncestorProtected, isBlockProtected } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
export { isAncestorProtected, isBlockProtected }
|
||||
|
||||
/**
|
||||
* Result of filtering protected blocks from a deletion operation
|
||||
@@ -15,6 +12,28 @@ export interface FilterProtectedBlocksResult {
|
||||
allProtected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected from editing/deletion.
|
||||
* A block is protected if it is locked or if its parent container is locked.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the block is protected
|
||||
*/
|
||||
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
|
||||
// Block is locked directly
|
||||
if (block.locked) return true
|
||||
|
||||
// Block is inside a locked container
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && blocks[parentId]?.locked) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an edge is protected from modification.
|
||||
* An edge is protected only if its target block is protected.
|
||||
|
||||
@@ -196,14 +196,17 @@ const edgeTypes: EdgeTypes = {
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
|
||||
const reactFlowStyles = [
|
||||
'bg-[var(--bg)]',
|
||||
'[&_.react-flow__edges]:!z-0',
|
||||
'[&_.react-flow__node]:z-[21]',
|
||||
'[&_.react-flow__handle]:!z-[30]',
|
||||
'[&_.react-flow__edge-labels]:!z-[1001]',
|
||||
'[&_.react-flow__edge-labels]:!z-[60]',
|
||||
'[&_.react-flow__pane]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__pane]:select-none',
|
||||
'[&_.react-flow__selectionpane]:select-none',
|
||||
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__background]:hidden',
|
||||
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
|
||||
].join(' ')
|
||||
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
|
||||
const reactFlowProOptions = { hideAttribution: true } as const
|
||||
@@ -2409,12 +2412,6 @@ const WorkflowContent = React.memo(() => {
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
|
||||
|
||||
// Compute zIndex for blocks inside containers so they render above the
|
||||
// parent subflow's interactive body area (which needs pointer-events for
|
||||
// click-to-select). Container nodes use zIndex: depth (0, 1, 2...),
|
||||
// so child blocks use a baseline that is always above any container.
|
||||
const childZIndex = block.data?.parentId ? 1000 : undefined
|
||||
|
||||
// Create stable node object - React Flow will handle shallow comparison
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
@@ -2423,7 +2420,6 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle,
|
||||
draggable: !isBlockProtected(block.id, blocks),
|
||||
...(childZIndex !== undefined && { zIndex: childZIndex }),
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
@@ -3772,20 +3768,21 @@ const WorkflowContent = React.memo(() => {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden'>
|
||||
<div className='relative h-full w-full flex-1'>
|
||||
{!isWorkflowReady && (
|
||||
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
|
||||
<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>
|
||||
)}
|
||||
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
|
||||
<div
|
||||
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
<div
|
||||
className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
|
||||
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>
|
||||
|
||||
{isWorkflowReady && (
|
||||
<>
|
||||
@@ -3838,7 +3835,7 @@ const WorkflowContent = React.memo(() => {
|
||||
noWheelClassName='allow-scroll'
|
||||
edgesFocusable={true}
|
||||
edgesUpdatable={effectivePermissions.canEdit}
|
||||
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
|
||||
@@ -3850,7 +3847,7 @@ const WorkflowContent = React.memo(() => {
|
||||
elevateEdgesOnSelect={true}
|
||||
onlyRenderVisibleElements={false}
|
||||
deleteKeyCode={null}
|
||||
elevateNodesOnSelect={false}
|
||||
elevateNodesOnSelect={true}
|
||||
autoPanOnConnect={effectivePermissions.canEdit}
|
||||
autoPanOnNodeDrag={effectivePermissions.canEdit}
|
||||
/>
|
||||
|
||||
@@ -129,30 +129,6 @@ Output: {"short_description": "Network outage", "description": "Network connecti
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'offset',
|
||||
title: 'Offset',
|
||||
type: 'short-input',
|
||||
placeholder: '0',
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
description: 'Number of records to skip for pagination',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'displayValue',
|
||||
title: 'Display Value',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Default (not set)', id: '' },
|
||||
{ label: 'False (sys_id only)', id: 'false' },
|
||||
{ label: 'True (display value only)', id: 'true' },
|
||||
{ label: 'All (both)', id: 'all' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
description: 'Return display values for reference fields instead of sys_ids',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Fields to Return',
|
||||
@@ -227,9 +203,6 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
const isCreateOrUpdate =
|
||||
operation === 'servicenow_create_record' || operation === 'servicenow_update_record'
|
||||
|
||||
if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit)
|
||||
if (rest.offset != null && rest.offset !== '') rest.offset = Number(rest.offset)
|
||||
|
||||
if (fields && isCreateOrUpdate) {
|
||||
const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields
|
||||
return { ...rest, fields: parsedFields }
|
||||
@@ -249,9 +222,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
number: { type: 'string', description: 'Record number' },
|
||||
query: { type: 'string', description: 'Query string' },
|
||||
limit: { type: 'number', description: 'Result limit' },
|
||||
offset: { type: 'number', description: 'Pagination offset' },
|
||||
fields: { type: 'json', description: 'Fields object or JSON string' },
|
||||
displayValue: { type: 'string', description: 'Display value mode for reference fields' },
|
||||
},
|
||||
outputs: {
|
||||
record: { type: 'json', description: 'Single ServiceNow record' },
|
||||
|
||||
@@ -9,10 +9,10 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
description:
|
||||
'Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack',
|
||||
'Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events',
|
||||
authMode: AuthMode.OAuth,
|
||||
longDescription:
|
||||
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
|
||||
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
|
||||
docsLink: 'https://docs.sim.ai/tools/slack',
|
||||
category: 'tools',
|
||||
bgColor: '#611f69',
|
||||
@@ -38,11 +38,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
{ label: 'Update Message', id: 'update' },
|
||||
{ label: 'Delete Message', id: 'delete' },
|
||||
{ label: 'Add Reaction', id: 'react' },
|
||||
{ label: 'Remove Reaction', id: 'unreact' },
|
||||
{ label: 'Get Channel Info', id: 'get_channel_info' },
|
||||
{ label: 'Get User Presence', id: 'get_user_presence' },
|
||||
{ label: 'Edit Canvas', id: 'edit_canvas' },
|
||||
{ label: 'Create Channel Canvas', id: 'create_channel_canvas' },
|
||||
],
|
||||
value: () => 'send',
|
||||
},
|
||||
@@ -146,7 +141,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
}
|
||||
return {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'],
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
not: true,
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
@@ -171,7 +166,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
}
|
||||
return {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'],
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
not: true,
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
@@ -214,26 +209,8 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
{
|
||||
id: 'ephemeralUser',
|
||||
title: 'Target User',
|
||||
type: 'user-selector',
|
||||
canonicalParamId: 'ephemeralUser',
|
||||
serviceId: 'slack',
|
||||
selectorKey: 'slack.users',
|
||||
placeholder: 'Select Slack user',
|
||||
mode: 'basic',
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'ephemeral',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualEphemeralUser',
|
||||
title: 'Target User ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'ephemeralUser',
|
||||
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
|
||||
mode: 'advanced',
|
||||
placeholder: 'User ID who will see the message (e.g., U1234567890)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'ephemeral',
|
||||
@@ -463,27 +440,9 @@ Do not include any explanations, markdown formatting, or other text outside the
|
||||
// Get User specific fields
|
||||
{
|
||||
id: 'userId',
|
||||
title: 'User',
|
||||
type: 'user-selector',
|
||||
canonicalParamId: 'userId',
|
||||
serviceId: 'slack',
|
||||
selectorKey: 'slack.users',
|
||||
placeholder: 'Select Slack user',
|
||||
mode: 'basic',
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualUserId',
|
||||
title: 'User ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'userId',
|
||||
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user',
|
||||
@@ -649,7 +608,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['react', 'unreact'],
|
||||
value: 'react',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
@@ -660,150 +619,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Emoji name without colons (e.g., thumbsup, heart, eyes)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['react', 'unreact'],
|
||||
value: 'react',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Get Channel Info specific fields
|
||||
{
|
||||
id: 'includeNumMembers',
|
||||
title: 'Include Member Count',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
value: () => 'true',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_channel_info',
|
||||
},
|
||||
},
|
||||
// Get User Presence specific fields
|
||||
{
|
||||
id: 'presenceUserId',
|
||||
title: 'User',
|
||||
type: 'user-selector',
|
||||
canonicalParamId: 'presenceUserId',
|
||||
serviceId: 'slack',
|
||||
selectorKey: 'slack.users',
|
||||
placeholder: 'Select Slack user',
|
||||
mode: 'basic',
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user_presence',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualPresenceUserId',
|
||||
title: 'User ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'presenceUserId',
|
||||
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user_presence',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Edit Canvas specific fields
|
||||
{
|
||||
id: 'editCanvasId',
|
||||
title: 'Canvas ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter canvas ID (e.g., F1234ABCD)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'canvasOperation',
|
||||
title: 'Edit Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Insert at Start', id: 'insert_at_start' },
|
||||
{ label: 'Insert at End', id: 'insert_at_end' },
|
||||
{ label: 'Insert After Section', id: 'insert_after' },
|
||||
{ label: 'Insert Before Section', id: 'insert_before' },
|
||||
{ label: 'Replace Section', id: 'replace' },
|
||||
{ label: 'Delete Section', id: 'delete' },
|
||||
{ label: 'Rename Canvas', id: 'rename' },
|
||||
],
|
||||
value: () => 'insert_at_end',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'canvasContent',
|
||||
title: 'Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter content in markdown format',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
and: {
|
||||
field: 'canvasOperation',
|
||||
value: ['delete', 'rename'],
|
||||
not: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sectionId',
|
||||
title: 'Section ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Section ID to target',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
and: {
|
||||
field: 'canvasOperation',
|
||||
value: ['insert_after', 'insert_before', 'replace', 'delete'],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'canvasTitle',
|
||||
title: 'New Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter new canvas title',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
and: { field: 'canvasOperation', value: 'rename' },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Create Channel Canvas specific fields
|
||||
{
|
||||
id: 'channelCanvasTitle',
|
||||
title: 'Canvas Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter canvas title (optional)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'create_channel_canvas',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'channelCanvasContent',
|
||||
title: 'Canvas Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter canvas content (markdown supported)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'create_channel_canvas',
|
||||
},
|
||||
},
|
||||
...getTrigger('slack_webhook').subBlocks,
|
||||
],
|
||||
tools: {
|
||||
@@ -822,11 +641,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
'slack_update_message',
|
||||
'slack_delete_message',
|
||||
'slack_add_reaction',
|
||||
'slack_remove_reaction',
|
||||
'slack_get_channel_info',
|
||||
'slack_get_user_presence',
|
||||
'slack_edit_canvas',
|
||||
'slack_create_channel_canvas',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -859,16 +673,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
return 'slack_delete_message'
|
||||
case 'react':
|
||||
return 'slack_add_reaction'
|
||||
case 'unreact':
|
||||
return 'slack_remove_reaction'
|
||||
case 'get_channel_info':
|
||||
return 'slack_get_channel_info'
|
||||
case 'get_user_presence':
|
||||
return 'slack_get_user_presence'
|
||||
case 'edit_canvas':
|
||||
return 'slack_edit_canvas'
|
||||
case 'create_channel_canvas':
|
||||
return 'slack_create_channel_canvas'
|
||||
default:
|
||||
throw new Error(`Invalid Slack operation: ${params.operation}`)
|
||||
}
|
||||
@@ -906,15 +710,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
getMessageTimestamp,
|
||||
getThreadTimestamp,
|
||||
threadLimit,
|
||||
includeNumMembers,
|
||||
presenceUserId,
|
||||
editCanvasId,
|
||||
canvasOperation,
|
||||
canvasContent,
|
||||
sectionId,
|
||||
canvasTitle,
|
||||
channelCanvasTitle,
|
||||
channelCanvasContent,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
@@ -1025,10 +820,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
|
||||
case 'download': {
|
||||
const fileId = (rest as any).fileId
|
||||
const fileName = (rest as any).fileName
|
||||
const downloadFileName = (rest as any).downloadFileName
|
||||
baseParams.fileId = fileId
|
||||
if (fileName) {
|
||||
baseParams.fileName = fileName
|
||||
if (downloadFileName) {
|
||||
baseParams.fileName = downloadFileName
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1046,41 +841,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
break
|
||||
|
||||
case 'react':
|
||||
case 'unreact':
|
||||
baseParams.timestamp = reactionTimestamp
|
||||
baseParams.name = emojiName
|
||||
break
|
||||
|
||||
case 'get_channel_info':
|
||||
baseParams.includeNumMembers = includeNumMembers !== 'false'
|
||||
break
|
||||
|
||||
case 'get_user_presence':
|
||||
baseParams.userId = presenceUserId
|
||||
break
|
||||
|
||||
case 'edit_canvas':
|
||||
baseParams.canvasId = editCanvasId
|
||||
baseParams.operation = canvasOperation
|
||||
if (canvasContent) {
|
||||
baseParams.content = canvasContent
|
||||
}
|
||||
if (sectionId) {
|
||||
baseParams.sectionId = sectionId
|
||||
}
|
||||
if (canvasTitle) {
|
||||
baseParams.title = canvasTitle
|
||||
}
|
||||
break
|
||||
|
||||
case 'create_channel_canvas':
|
||||
if (channelCanvasTitle) {
|
||||
baseParams.title = channelCanvasTitle
|
||||
}
|
||||
if (channelCanvasContent) {
|
||||
baseParams.content = channelCanvasContent
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return baseParams
|
||||
@@ -1135,19 +898,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
type: 'string',
|
||||
description: 'Maximum number of messages to return from thread',
|
||||
},
|
||||
// Get Channel Info inputs
|
||||
includeNumMembers: { type: 'string', description: 'Include member count (true/false)' },
|
||||
// Get User Presence inputs
|
||||
presenceUserId: { type: 'string', description: 'User ID to check presence for' },
|
||||
// Edit Canvas inputs
|
||||
editCanvasId: { type: 'string', description: 'Canvas ID to edit' },
|
||||
canvasOperation: { type: 'string', description: 'Canvas edit operation' },
|
||||
canvasContent: { type: 'string', description: 'Markdown content for canvas edit' },
|
||||
sectionId: { type: 'string', description: 'Canvas section ID to target' },
|
||||
canvasTitle: { type: 'string', description: 'New canvas title for rename' },
|
||||
// Create Channel Canvas inputs
|
||||
channelCanvasTitle: { type: 'string', description: 'Title for channel canvas' },
|
||||
channelCanvasContent: { type: 'string', description: 'Content for channel canvas' },
|
||||
},
|
||||
outputs: {
|
||||
// slack_message outputs (send operation)
|
||||
@@ -1244,43 +994,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
description: 'Updated message metadata (legacy, use message object instead)',
|
||||
},
|
||||
|
||||
// slack_get_channel_info outputs (get_channel_info operation)
|
||||
channelInfo: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Detailed channel object with properties: id, name, is_private, is_archived, is_member, num_members, topic, purpose, created, creator',
|
||||
},
|
||||
|
||||
// slack_get_user_presence outputs (get_user_presence operation)
|
||||
presence: {
|
||||
type: 'string',
|
||||
description: 'User presence status: "active" or "away"',
|
||||
},
|
||||
online: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user has an active client connection (only available when checking own presence)',
|
||||
},
|
||||
autoAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user was automatically set to away (only available when checking own presence)',
|
||||
},
|
||||
manualAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user manually set themselves as away (only available when checking own presence)',
|
||||
},
|
||||
connectionCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of active connections (only available when checking own presence)',
|
||||
},
|
||||
lastActivity: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Unix timestamp of last detected activity (only available when checking own presence)',
|
||||
},
|
||||
|
||||
// Trigger outputs (when used as webhook trigger)
|
||||
event_type: { type: 'string', description: 'Type of Slack event that triggered the workflow' },
|
||||
channel_name: { type: 'string', description: 'Human-readable channel name' },
|
||||
|
||||
@@ -618,8 +618,6 @@ export class BlockExecutor {
|
||||
await ctx.onStream?.(clientStreamingExec)
|
||||
} catch (error) {
|
||||
logger.error('Error in onStream callback', { blockId, error })
|
||||
// Cancel the client stream to release the tee'd buffer
|
||||
await processedClientStream.cancel().catch(() => {})
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -648,7 +646,6 @@ export class BlockExecutor {
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error in onStream callback', { blockId, error })
|
||||
await processedStream.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,25 +657,22 @@ export class BlockExecutor {
|
||||
): Promise<void> {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const chunks: string[] = []
|
||||
let fullContent = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(decoder.decode(value, { stream: true }))
|
||||
fullContent += decoder.decode(value, { stream: true })
|
||||
}
|
||||
const tail = decoder.decode()
|
||||
if (tail) chunks.push(tail)
|
||||
} catch (error) {
|
||||
logger.error('Error reading executor stream for block', { blockId, error })
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel().catch(() => {})
|
||||
reader.releaseLock()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fullContent = chunks.join('')
|
||||
if (!fullContent) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
|
||||
import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('CollaborativeWorkflow')
|
||||
|
||||
@@ -749,7 +748,9 @@ export function useCollaborativeWorkflow() {
|
||||
const block = blocks[id]
|
||||
|
||||
if (block) {
|
||||
if (isBlockProtected(id, blocks)) {
|
||||
const parentId = block.data?.parentId
|
||||
const isParentLocked = parentId ? blocks[parentId]?.locked : false
|
||||
if (block.locked || isParentLocked) {
|
||||
logger.error('Cannot rename locked block')
|
||||
useNotificationStore.getState().addNotification({
|
||||
level: 'info',
|
||||
@@ -857,21 +858,21 @@ export function useCollaborativeWorkflow() {
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
// For each ID, collect non-locked blocks and their descendants for undo/redo
|
||||
// For each ID, collect non-locked blocks and their children for undo/redo
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
// Skip protected blocks (locked or inside a locked ancestor)
|
||||
if (isBlockProtected(id, currentBlocks)) continue
|
||||
// Skip locked blocks
|
||||
if (block.locked) continue
|
||||
validIds.push(id)
|
||||
previousStates[id] = block.enabled
|
||||
|
||||
// If it's a loop or parallel, also capture descendants' previous states for undo/redo
|
||||
// If it's a loop or parallel, also capture children's previous states for undo/redo
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
if (!isBlockProtected(descId, currentBlocks)) {
|
||||
previousStates[descId] = currentBlocks[descId]?.enabled ?? true
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id && !b.locked) {
|
||||
previousStates[blockId] = b.enabled
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1037,12 +1038,21 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && blocks[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
const block = blocks[id]
|
||||
if (block && !isBlockProtected(id, blocks)) {
|
||||
if (block && !isProtected(id)) {
|
||||
previousStates[id] = block.horizontalHandles ?? false
|
||||
validIds.push(id)
|
||||
}
|
||||
@@ -1090,8 +1100,10 @@ export function useCollaborativeWorkflow() {
|
||||
previousStates[id] = block.locked ?? false
|
||||
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
previousStates[descId] = currentBlocks[descId]?.locked ?? false
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id) {
|
||||
previousStates[blockId] = b.locked ?? false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,6 @@ export function startMemoryTelemetry(intervalMs = 60_000) {
|
||||
started = true
|
||||
|
||||
const timer = setInterval(() => {
|
||||
// Trigger opportunistic (non-blocking) garbage collection if running on Bun.
|
||||
// This signals JSC GC + mimalloc page purge without blocking the event loop,
|
||||
// helping reclaim RSS that mimalloc otherwise retains under sustained load.
|
||||
const bunGlobal = (globalThis as Record<string, unknown>).Bun as
|
||||
| { gc?: (force: boolean) => void }
|
||||
| undefined
|
||||
if (typeof bunGlobal?.gc === 'function') {
|
||||
bunGlobal.gc(false)
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
const heap = v8.getHeapStatistics()
|
||||
|
||||
|
||||
@@ -759,7 +759,6 @@ async function markEmailAsRead(accessToken: string, messageId: string) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
throw new Error(
|
||||
`Failed to mark email ${messageId} as read: ${response.status} ${response.statusText}`
|
||||
)
|
||||
|
||||
@@ -95,7 +95,6 @@ const nextConfig: NextConfig = {
|
||||
optimizeCss: true,
|
||||
turbopackSourceMaps: false,
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
},
|
||||
...(isDev && {
|
||||
allowedDevOrigins: [
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"@trigger.dev/sdk": "4.1.2",
|
||||
"@types/react-window": "2.0.0",
|
||||
"@types/three": "0.177.0",
|
||||
"acorn": "8.16.0",
|
||||
"better-auth": "1.3.12",
|
||||
"binary-extensions": "^2.0.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
|
||||
@@ -39,56 +39,6 @@ const db = socketDb
|
||||
const DEFAULT_LOOP_ITERATIONS = 5
|
||||
const DEFAULT_PARALLEL_COUNT = 5
|
||||
|
||||
/** Minimal block shape needed for protection and descendant checks */
|
||||
interface DbBlockRef {
|
||||
id: string
|
||||
locked?: boolean | null
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected (locked or inside a locked ancestor).
|
||||
* Works with raw DB records.
|
||||
*/
|
||||
function isDbBlockProtected(blockId: string, blocksById: Record<string, DbBlockRef>): boolean {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const visited = new Set<string>()
|
||||
let parentId = (block.data as Record<string, unknown> | null)?.parentId as string | undefined
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
visited.add(parentId)
|
||||
if (blocksById[parentId]?.locked) return true
|
||||
parentId = (blocksById[parentId]?.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all descendant block IDs of a container (recursive).
|
||||
* Works with raw DB block arrays.
|
||||
*/
|
||||
function findDbDescendants(containerId: string, allBlocks: DbBlockRef[]): string[] {
|
||||
const descendants: string[] = []
|
||||
const visited = new Set<string>()
|
||||
const stack = [containerId]
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!
|
||||
if (visited.has(current)) continue
|
||||
visited.add(current)
|
||||
for (const b of allBlocks) {
|
||||
const pid = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (pid === current) {
|
||||
descendants.push(b.id)
|
||||
stack.push(b.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared function to handle auto-connect edge insertion
|
||||
* @param tx - Database transaction
|
||||
@@ -803,8 +753,20 @@ async function handleBlocksOperationTx(
|
||||
allBlocks.map((b: BlockRecord) => [b.id, b])
|
||||
)
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter out protected blocks from deletion request
|
||||
const deletableIds = ids.filter((id) => !isDbBlockProtected(id, blocksById))
|
||||
const deletableIds = ids.filter((id) => !isProtected(id))
|
||||
if (deletableIds.length === 0) {
|
||||
logger.info('All requested blocks are protected, skipping deletion')
|
||||
return
|
||||
@@ -816,14 +778,18 @@ async function handleBlocksOperationTx(
|
||||
)
|
||||
}
|
||||
|
||||
// Collect all block IDs including all descendants of subflows
|
||||
// Collect all block IDs including children of subflows
|
||||
const allBlocksToDelete = new Set<string>(deletableIds)
|
||||
|
||||
for (const id of deletableIds) {
|
||||
const block = blocksById[id]
|
||||
if (block && isSubflowBlockType(block.type)) {
|
||||
for (const descId of findDbDescendants(id, allBlocks)) {
|
||||
allBlocksToDelete.add(descId)
|
||||
// Include all children of the subflow (they should be deleted with parent)
|
||||
for (const b of allBlocks) {
|
||||
const parentId = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (parentId === id) {
|
||||
allBlocksToDelete.add(b.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,18 +902,19 @@ async function handleBlocksOperationTx(
|
||||
)
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// Collect all blocks to toggle including descendants of containers
|
||||
// Collect all blocks to toggle including children of containers
|
||||
for (const id of blockIds) {
|
||||
const block = blocksById[id]
|
||||
if (!block || isDbBlockProtected(id, blocksById)) continue
|
||||
if (!block || block.locked) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include all non-locked descendants
|
||||
// If it's a loop or parallel, also include all children
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
for (const descId of findDbDescendants(id, allBlocks)) {
|
||||
if (!isDbBlockProtected(descId, blocksById)) {
|
||||
blocksToToggle.add(descId)
|
||||
for (const b of allBlocks) {
|
||||
const parentId = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (parentId === id && !b.locked) {
|
||||
blocksToToggle.add(b.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,10 +966,20 @@ async function handleBlocksOperationTx(
|
||||
allBlocks.map((b: HandleBlockRecord) => [b.id, b])
|
||||
)
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter to only toggle handles on unprotected blocks
|
||||
const blocksToToggle = blockIds.filter(
|
||||
(id) => blocksById[id] && !isDbBlockProtected(id, blocksById)
|
||||
)
|
||||
const blocksToToggle = blockIds.filter((id) => blocksById[id] && !isProtected(id))
|
||||
if (blocksToToggle.length === 0) {
|
||||
logger.info('All requested blocks are protected, skipping handles toggle')
|
||||
break
|
||||
@@ -1048,17 +1025,20 @@ async function handleBlocksOperationTx(
|
||||
)
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// Collect all blocks to toggle including descendants of containers
|
||||
// Collect all blocks to toggle including children of containers
|
||||
for (const id of blockIds) {
|
||||
const block = blocksById[id]
|
||||
if (!block) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include all descendants
|
||||
// If it's a loop or parallel, also include all children
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
for (const descId of findDbDescendants(id, allBlocks)) {
|
||||
blocksToToggle.add(descId)
|
||||
for (const b of allBlocks) {
|
||||
const parentId = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (parentId === id) {
|
||||
blocksToToggle.add(b.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1108,19 +1088,31 @@ async function handleBlocksOperationTx(
|
||||
allBlocks.map((b: ParentBlockRecord) => [b.id, b])
|
||||
)
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const currentParentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (currentParentId && blocksById[currentParentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
const { id, parentId, position } = update
|
||||
if (!id) continue
|
||||
|
||||
// Skip protected blocks (locked or inside locked container)
|
||||
if (isDbBlockProtected(id, blocksById)) {
|
||||
if (isProtected(id)) {
|
||||
logger.info(`Skipping block ${id} parent update - block is protected`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if trying to move into a locked container (or any of its ancestors)
|
||||
if (parentId && isDbBlockProtected(parentId, blocksById)) {
|
||||
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`)
|
||||
// Skip if trying to move into a locked container
|
||||
if (parentId && blocksById[parentId]?.locked) {
|
||||
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is locked`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1243,7 +1235,18 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
|
||||
}
|
||||
}
|
||||
|
||||
if (isDbBlockProtected(payload.target, blocksById)) {
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (isBlockProtected(payload.target)) {
|
||||
logger.info(`Skipping edge add - target block is protected`)
|
||||
break
|
||||
}
|
||||
@@ -1331,7 +1334,18 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
|
||||
}
|
||||
}
|
||||
|
||||
if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) {
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (isBlockProtected(edgeToRemove.targetBlockId)) {
|
||||
logger.info(`Skipping edge remove - target block is protected`)
|
||||
break
|
||||
}
|
||||
@@ -1441,8 +1455,19 @@ async function handleEdgesOperationTx(
|
||||
}
|
||||
}
|
||||
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const safeEdgeIds = edgesToRemove
|
||||
.filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById))
|
||||
.filter((e: EdgeToRemove) => !isBlockProtected(e.targetBlockId))
|
||||
.map((e: EdgeToRemove) => e.id)
|
||||
|
||||
if (safeEdgeIds.length === 0) {
|
||||
@@ -1527,9 +1552,20 @@ async function handleEdgesOperationTx(
|
||||
}
|
||||
}
|
||||
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter edges - only add edges where target block is not protected
|
||||
const safeEdges = (edges as Array<Record<string, unknown>>).filter(
|
||||
(e) => !isDbBlockProtected(e.target as string, blocksById)
|
||||
(e) => !isBlockProtected(e.target as string)
|
||||
)
|
||||
|
||||
if (safeEdges.length === 0) {
|
||||
|
||||
@@ -20,10 +20,8 @@ import type {
|
||||
WorkflowStore,
|
||||
} from '@/stores/workflows/workflow/types'
|
||||
import {
|
||||
findAllDescendantNodes,
|
||||
generateLoopBlocks,
|
||||
generateParallelBlocks,
|
||||
isBlockProtected,
|
||||
wouldCreateCycle,
|
||||
} from '@/stores/workflows/workflow/utils'
|
||||
|
||||
@@ -376,21 +374,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// For each ID, collect blocks to toggle (skip locked blocks entirely)
|
||||
// If it's a container, also include non-locked descendants
|
||||
// If it's a container, also include non-locked children
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
// Skip protected blocks entirely (locked or inside a locked ancestor)
|
||||
if (isBlockProtected(id, currentBlocks)) continue
|
||||
// Skip locked blocks entirely (including their children)
|
||||
if (block.locked) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include non-locked descendants
|
||||
// If it's a loop or parallel, also include non-locked children
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
if (!isBlockProtected(descId, currentBlocks)) {
|
||||
blocksToToggle.add(descId)
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id && !b.locked) {
|
||||
blocksToToggle.add(blockId)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -417,8 +415,18 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const currentBlocks = get().blocks
|
||||
const newBlocks = { ...currentBlocks }
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = currentBlocks[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && currentBlocks[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
if (!newBlocks[id] || isBlockProtected(id, currentBlocks)) continue
|
||||
if (!newBlocks[id] || isProtected(id)) continue
|
||||
newBlocks[id] = {
|
||||
...newBlocks[id],
|
||||
horizontalHandles: !newBlocks[id].horizontalHandles,
|
||||
@@ -1259,17 +1267,19 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// For each ID, collect blocks to toggle
|
||||
// If it's a container, also include all descendants
|
||||
// If it's a container, also include all children
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include all descendants
|
||||
// If it's a loop or parallel, also include all children
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
blocksToToggle.add(descId)
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id) {
|
||||
blocksToToggle.add(blockId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,56 +143,21 @@ export function findAllDescendantNodes(
|
||||
blocks: Record<string, BlockState>
|
||||
): string[] {
|
||||
const descendants: string[] = []
|
||||
const visited = new Set<string>()
|
||||
const stack = [containerId]
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!
|
||||
if (visited.has(current)) continue
|
||||
visited.add(current)
|
||||
for (const block of Object.values(blocks)) {
|
||||
if (block.data?.parentId === current) {
|
||||
descendants.push(block.id)
|
||||
stack.push(block.id)
|
||||
}
|
||||
}
|
||||
const findDescendants = (parentId: string) => {
|
||||
const children = Object.values(blocks)
|
||||
.filter((block) => block.data?.parentId === parentId)
|
||||
.map((block) => block.id)
|
||||
|
||||
children.forEach((childId) => {
|
||||
descendants.push(childId)
|
||||
findDescendants(childId)
|
||||
})
|
||||
}
|
||||
|
||||
findDescendants(containerId)
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any ancestor container of a block is locked.
|
||||
* Unlike {@link isBlockProtected}, this ignores the block's own locked state.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if any ancestor is locked
|
||||
*/
|
||||
export function isAncestorProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const visited = new Set<string>()
|
||||
let parentId = blocks[blockId]?.data?.parentId
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
visited.add(parentId)
|
||||
if (blocks[parentId]?.locked) return true
|
||||
parentId = blocks[parentId]?.data?.parentId
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected from editing/deletion.
|
||||
* A block is protected if it is locked or if any ancestor container is locked.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the block is protected
|
||||
*/
|
||||
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
return isAncestorProtected(blockId, blocks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete collection of loops from the UI blocks
|
||||
*
|
||||
|
||||
@@ -239,7 +239,6 @@ export async function downloadAttachments(
|
||||
)
|
||||
|
||||
if (!attachmentResponse.ok) {
|
||||
await attachmentResponse.body?.cancel().catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1797,22 +1797,17 @@ import {
|
||||
import {
|
||||
slackAddReactionTool,
|
||||
slackCanvasTool,
|
||||
slackCreateChannelCanvasTool,
|
||||
slackDeleteMessageTool,
|
||||
slackDownloadTool,
|
||||
slackEditCanvasTool,
|
||||
slackEphemeralMessageTool,
|
||||
slackGetChannelInfoTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
slackGetUserPresenceTool,
|
||||
slackGetUserTool,
|
||||
slackListChannelsTool,
|
||||
slackListMembersTool,
|
||||
slackListUsersTool,
|
||||
slackMessageReaderTool,
|
||||
slackMessageTool,
|
||||
slackRemoveReactionTool,
|
||||
slackUpdateMessageTool,
|
||||
} from '@/tools/slack'
|
||||
import { smsSendTool } from '@/tools/sms'
|
||||
@@ -2616,11 +2611,6 @@ export const tools: Record<string, ToolConfig> = {
|
||||
slack_update_message: slackUpdateMessageTool,
|
||||
slack_delete_message: slackDeleteMessageTool,
|
||||
slack_add_reaction: slackAddReactionTool,
|
||||
slack_remove_reaction: slackRemoveReactionTool,
|
||||
slack_get_channel_info: slackGetChannelInfoTool,
|
||||
slack_get_user_presence: slackGetUserPresenceTool,
|
||||
slack_edit_canvas: slackEditCanvasTool,
|
||||
slack_create_channel_canvas: slackCreateChannelCanvasTool,
|
||||
github_repo_info: githubRepoInfoTool,
|
||||
github_repo_info_v2: githubRepoInfoV2Tool,
|
||||
github_latest_commit: githubLatestCommitTool,
|
||||
|
||||
@@ -60,12 +60,6 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of records to return (e.g., 10, 50, 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of records to skip for pagination (e.g., 0, 10, 20)',
|
||||
},
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -73,13 +67,6 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
|
||||
description:
|
||||
'Comma-separated list of fields to return (e.g., sys_id,number,short_description,state)',
|
||||
},
|
||||
displayValue: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Return display values for reference fields: "true" (display only), "false" (sys_id only), or "all" (both)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -109,18 +96,10 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
|
||||
queryParams.append('sysparm_limit', params.limit.toString())
|
||||
}
|
||||
|
||||
if (params.offset !== undefined && params.offset !== null) {
|
||||
queryParams.append('sysparm_offset', params.offset.toString())
|
||||
}
|
||||
|
||||
if (params.fields) {
|
||||
queryParams.append('sysparm_fields', params.fields)
|
||||
}
|
||||
|
||||
if (params.displayValue) {
|
||||
queryParams.append('sysparm_display_value', params.displayValue)
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
return queryString ? `${url}?${queryString}` : url
|
||||
},
|
||||
|
||||
@@ -31,9 +31,7 @@ export interface ServiceNowReadParams extends ServiceNowBaseParams {
|
||||
number?: string
|
||||
query?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
fields?: string
|
||||
displayValue?: string
|
||||
}
|
||||
|
||||
export interface ServiceNowReadResponse extends ToolResponse {
|
||||
|
||||
@@ -87,21 +87,9 @@ export const slackCanvasTool: ToolConfig<SlackCanvasParams, SlackCanvasResponse>
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<SlackCanvasResponse> => {
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
canvas_id: '',
|
||||
channel: '',
|
||||
title: '',
|
||||
},
|
||||
error: data.error || 'Unknown error',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import type {
|
||||
SlackCreateChannelCanvasParams,
|
||||
SlackCreateChannelCanvasResponse,
|
||||
} from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackCreateChannelCanvasTool: ToolConfig<
|
||||
SlackCreateChannelCanvasParams,
|
||||
SlackCreateChannelCanvasResponse
|
||||
> = {
|
||||
id: 'slack_create_channel_canvas',
|
||||
name: 'Slack Create Channel Canvas',
|
||||
description: 'Create a canvas pinned to a Slack channel as its resource hub',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Channel ID to create the canvas in (e.g., C1234567890)',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Title for the channel canvas',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Canvas content in markdown format',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://slack.com/api/conversations.canvases.create',
|
||||
method: 'POST',
|
||||
headers: (params: SlackCreateChannelCanvasParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
body: (params: SlackCreateChannelCanvasParams) => {
|
||||
const body: Record<string, unknown> = {
|
||||
channel_id: params.channel.trim(),
|
||||
}
|
||||
|
||||
if (params.title) {
|
||||
body.title = params.title
|
||||
}
|
||||
|
||||
if (params.content) {
|
||||
body.document_content = {
|
||||
type: 'markdown',
|
||||
markdown: params.content,
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'channel_canvas_already_exists') {
|
||||
throw new Error('A canvas already exists for this channel. Use Edit Canvas to modify it.')
|
||||
}
|
||||
throw new Error(data.error || 'Failed to create channel canvas')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
canvas_id: data.canvas_id,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
canvas_id: { type: 'string', description: 'ID of the created channel canvas' },
|
||||
},
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { SlackEditCanvasParams, SlackEditCanvasResponse } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackEditCanvasTool: ToolConfig<SlackEditCanvasParams, SlackEditCanvasResponse> = {
|
||||
id: 'slack_edit_canvas',
|
||||
name: 'Slack Edit Canvas',
|
||||
description: 'Edit an existing Slack canvas by inserting, replacing, or deleting content',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
canvasId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Canvas ID to edit (e.g., F1234ABCD)',
|
||||
},
|
||||
operation: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Edit operation: insert_at_start, insert_at_end, insert_after, insert_before, replace, delete, or rename',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Markdown content for the operation (required for insert/replace operations)',
|
||||
},
|
||||
sectionId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Section ID to target (required for insert_after, insert_before, replace, and delete)',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'New title for the canvas (only used with rename operation)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://slack.com/api/canvases.edit',
|
||||
method: 'POST',
|
||||
headers: (params: SlackEditCanvasParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
body: (params: SlackEditCanvasParams) => {
|
||||
const change: Record<string, unknown> = {
|
||||
operation: params.operation,
|
||||
}
|
||||
|
||||
if (params.sectionId) {
|
||||
change.section_id = params.sectionId.trim()
|
||||
}
|
||||
|
||||
if (params.operation === 'rename' && params.title) {
|
||||
change.title_content = {
|
||||
type: 'markdown',
|
||||
markdown: params.title,
|
||||
}
|
||||
} else if (params.content && params.operation !== 'delete') {
|
||||
change.document_content = {
|
||||
type: 'markdown',
|
||||
markdown: params.content,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canvas_id: params.canvasId.trim(),
|
||||
changes: [change],
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Failed to edit canvas')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: 'Successfully edited canvas',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Success message' },
|
||||
},
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { SlackGetChannelInfoParams, SlackGetChannelInfoResponse } from '@/tools/slack/types'
|
||||
import { CHANNEL_OUTPUT_PROPERTIES } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackGetChannelInfoTool: ToolConfig<
|
||||
SlackGetChannelInfoParams,
|
||||
SlackGetChannelInfoResponse
|
||||
> = {
|
||||
id: 'slack_get_channel_info',
|
||||
name: 'Slack Get Channel Info',
|
||||
description: 'Get detailed information about a Slack channel by its ID',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Channel ID to get information about (e.g., C1234567890)',
|
||||
},
|
||||
includeNumMembers: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include the member count in the response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: SlackGetChannelInfoParams) => {
|
||||
const url = new URL('https://slack.com/api/conversations.info')
|
||||
url.searchParams.append('channel', params.channel.trim())
|
||||
url.searchParams.append('include_num_members', String(params.includeNumMembers ?? true))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: SlackGetChannelInfoParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'channel_not_found') {
|
||||
throw new Error('Channel not found. Please check the channel ID and try again.')
|
||||
}
|
||||
if (data.error === 'missing_scope') {
|
||||
throw new Error(
|
||||
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:read).'
|
||||
)
|
||||
}
|
||||
throw new Error(data.error || 'Failed to get channel info from Slack')
|
||||
}
|
||||
|
||||
const channel = data.channel
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
channelInfo: {
|
||||
id: channel.id,
|
||||
name: channel.name ?? '',
|
||||
is_channel: channel.is_channel ?? false,
|
||||
is_private: channel.is_private ?? false,
|
||||
is_archived: channel.is_archived ?? false,
|
||||
is_general: channel.is_general ?? false,
|
||||
is_member: channel.is_member ?? false,
|
||||
is_shared: channel.is_shared ?? false,
|
||||
is_ext_shared: channel.is_ext_shared ?? false,
|
||||
is_org_shared: channel.is_org_shared ?? false,
|
||||
num_members: channel.num_members ?? null,
|
||||
topic: channel.topic?.value ?? '',
|
||||
purpose: channel.purpose?.value ?? '',
|
||||
created: channel.created ?? null,
|
||||
creator: channel.creator ?? null,
|
||||
updated: channel.updated ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
channelInfo: {
|
||||
type: 'object',
|
||||
description: 'Detailed channel information',
|
||||
properties: CHANNEL_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { SlackGetUserPresenceParams, SlackGetUserPresenceResponse } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackGetUserPresenceTool: ToolConfig<
|
||||
SlackGetUserPresenceParams,
|
||||
SlackGetUserPresenceResponse
|
||||
> = {
|
||||
id: 'slack_get_user_presence',
|
||||
name: 'Slack Get User Presence',
|
||||
description: 'Check whether a Slack user is currently active or away',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User ID to check presence for (e.g., U1234567890)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: SlackGetUserPresenceParams) => {
|
||||
const url = new URL('https://slack.com/api/users.getPresence')
|
||||
url.searchParams.append('user', params.userId.trim())
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: SlackGetUserPresenceParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'user_not_found') {
|
||||
throw new Error('User not found. Please check the user ID and try again.')
|
||||
}
|
||||
if (data.error === 'missing_scope') {
|
||||
throw new Error(
|
||||
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (users:read).'
|
||||
)
|
||||
}
|
||||
throw new Error(data.error || 'Failed to get user presence from Slack')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
presence: data.presence,
|
||||
online: data.online ?? null,
|
||||
autoAway: data.auto_away ?? null,
|
||||
manualAway: data.manual_away ?? null,
|
||||
connectionCount: data.connection_count ?? null,
|
||||
lastActivity: data.last_activity ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
presence: {
|
||||
type: 'string',
|
||||
description: 'User presence status: "active" or "away"',
|
||||
},
|
||||
online: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user has an active client connection (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
autoAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user was automatically set to away due to inactivity (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
manualAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user manually set themselves as away (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
connectionCount: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Total number of active connections for the user (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
lastActivity: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Unix timestamp of last detected activity (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,41 +1,31 @@
|
||||
import { slackAddReactionTool } from '@/tools/slack/add_reaction'
|
||||
import { slackCanvasTool } from '@/tools/slack/canvas'
|
||||
import { slackCreateChannelCanvasTool } from '@/tools/slack/create_channel_canvas'
|
||||
import { slackDeleteMessageTool } from '@/tools/slack/delete_message'
|
||||
import { slackDownloadTool } from '@/tools/slack/download'
|
||||
import { slackEditCanvasTool } from '@/tools/slack/edit_canvas'
|
||||
import { slackEphemeralMessageTool } from '@/tools/slack/ephemeral_message'
|
||||
import { slackGetChannelInfoTool } from '@/tools/slack/get_channel_info'
|
||||
import { slackGetMessageTool } from '@/tools/slack/get_message'
|
||||
import { slackGetThreadTool } from '@/tools/slack/get_thread'
|
||||
import { slackGetUserTool } from '@/tools/slack/get_user'
|
||||
import { slackGetUserPresenceTool } from '@/tools/slack/get_user_presence'
|
||||
import { slackListChannelsTool } from '@/tools/slack/list_channels'
|
||||
import { slackListMembersTool } from '@/tools/slack/list_members'
|
||||
import { slackListUsersTool } from '@/tools/slack/list_users'
|
||||
import { slackMessageTool } from '@/tools/slack/message'
|
||||
import { slackMessageReaderTool } from '@/tools/slack/message_reader'
|
||||
import { slackRemoveReactionTool } from '@/tools/slack/remove_reaction'
|
||||
import { slackUpdateMessageTool } from '@/tools/slack/update_message'
|
||||
|
||||
export {
|
||||
slackMessageTool,
|
||||
slackCanvasTool,
|
||||
slackCreateChannelCanvasTool,
|
||||
slackMessageReaderTool,
|
||||
slackDownloadTool,
|
||||
slackEditCanvasTool,
|
||||
slackEphemeralMessageTool,
|
||||
slackUpdateMessageTool,
|
||||
slackDeleteMessageTool,
|
||||
slackAddReactionTool,
|
||||
slackRemoveReactionTool,
|
||||
slackGetChannelInfoTool,
|
||||
slackListChannelsTool,
|
||||
slackListMembersTool,
|
||||
slackListUsersTool,
|
||||
slackGetUserTool,
|
||||
slackGetUserPresenceTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { SlackRemoveReactionParams, SlackRemoveReactionResponse } from '@/tools/slack/types'
|
||||
import { REACTION_METADATA_OUTPUT_PROPERTIES } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackRemoveReactionTool: ToolConfig<
|
||||
SlackRemoveReactionParams,
|
||||
SlackRemoveReactionResponse
|
||||
> = {
|
||||
id: 'slack_remove_reaction',
|
||||
name: 'Slack Remove Reaction',
|
||||
description: 'Remove an emoji reaction from a Slack message',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Channel ID where the message was posted (e.g., C1234567890)',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Timestamp of the message to remove reaction from (e.g., 1405894322.002768)',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Name of the emoji reaction to remove (without colons, e.g., thumbsup, heart, eyes)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/slack/remove-reaction',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: SlackRemoveReactionParams) => ({
|
||||
accessToken: params.accessToken || params.botToken,
|
||||
channel: params.channel,
|
||||
timestamp: params.timestamp,
|
||||
name: params.name,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
content: data.error || 'Failed to remove reaction',
|
||||
metadata: {
|
||||
channel: '',
|
||||
timestamp: '',
|
||||
reaction: '',
|
||||
},
|
||||
},
|
||||
error: data.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: data.output.content,
|
||||
metadata: data.output.metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Success message' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Reaction metadata',
|
||||
properties: REACTION_METADATA_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -561,12 +561,6 @@ export interface SlackAddReactionParams extends SlackBaseParams {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SlackRemoveReactionParams extends SlackBaseParams {
|
||||
channel: string
|
||||
timestamp: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SlackListChannelsParams extends SlackBaseParams {
|
||||
includePrivate?: boolean
|
||||
excludeArchived?: boolean
|
||||
@@ -606,29 +600,6 @@ export interface SlackGetThreadParams extends SlackBaseParams {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SlackGetChannelInfoParams extends SlackBaseParams {
|
||||
channel: string
|
||||
includeNumMembers?: boolean
|
||||
}
|
||||
|
||||
export interface SlackGetUserPresenceParams extends SlackBaseParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface SlackEditCanvasParams extends SlackBaseParams {
|
||||
canvasId: string
|
||||
operation: string
|
||||
content?: string
|
||||
sectionId?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface SlackCreateChannelCanvasParams extends SlackBaseParams {
|
||||
channel: string
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface SlackMessageResponse extends ToolResponse {
|
||||
output: {
|
||||
// Legacy properties for backward compatibility
|
||||
@@ -788,34 +759,17 @@ export interface SlackAddReactionResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackRemoveReactionResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
metadata: {
|
||||
channel: string
|
||||
timestamp: string
|
||||
reaction: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackChannel {
|
||||
id: string
|
||||
name: string
|
||||
is_channel?: boolean
|
||||
is_private: boolean
|
||||
is_archived: boolean
|
||||
is_general?: boolean
|
||||
is_member: boolean
|
||||
is_shared?: boolean
|
||||
is_ext_shared?: boolean
|
||||
is_org_shared?: boolean
|
||||
num_members?: number
|
||||
topic?: string
|
||||
purpose?: string
|
||||
created?: number
|
||||
creator?: string
|
||||
updated?: number
|
||||
}
|
||||
|
||||
export interface SlackListChannelsResponse extends ToolResponse {
|
||||
@@ -904,35 +858,6 @@ export interface SlackGetThreadResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetChannelInfoResponse extends ToolResponse {
|
||||
output: {
|
||||
channelInfo: SlackChannel
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetUserPresenceResponse extends ToolResponse {
|
||||
output: {
|
||||
presence: string
|
||||
online?: boolean | null
|
||||
autoAway?: boolean | null
|
||||
manualAway?: boolean | null
|
||||
connectionCount?: number | null
|
||||
lastActivity?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackEditCanvasResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackCreateChannelCanvasResponse extends ToolResponse {
|
||||
output: {
|
||||
canvas_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SlackResponse =
|
||||
| SlackCanvasResponse
|
||||
| SlackMessageReaderResponse
|
||||
@@ -941,7 +866,6 @@ export type SlackResponse =
|
||||
| SlackUpdateMessageResponse
|
||||
| SlackDeleteMessageResponse
|
||||
| SlackAddReactionResponse
|
||||
| SlackRemoveReactionResponse
|
||||
| SlackListChannelsResponse
|
||||
| SlackListMembersResponse
|
||||
| SlackListUsersResponse
|
||||
@@ -949,7 +873,3 @@ export type SlackResponse =
|
||||
| SlackEphemeralMessageResponse
|
||||
| SlackGetMessageResponse
|
||||
| SlackGetThreadResponse
|
||||
| SlackGetChannelInfoResponse
|
||||
| SlackGetUserPresenceResponse
|
||||
| SlackEditCanvasResponse
|
||||
| SlackCreateChannelCanvasResponse
|
||||
|
||||
29
bun.lock
29
bun.lock
@@ -13,7 +13,7 @@
|
||||
"glob": "13.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.8.13",
|
||||
"turbo": "2.8.12",
|
||||
},
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -115,6 +115,7 @@
|
||||
"@trigger.dev/sdk": "4.1.2",
|
||||
"@types/react-window": "2.0.0",
|
||||
"@types/three": "0.177.0",
|
||||
"acorn": "8.16.0",
|
||||
"better-auth": "1.3.12",
|
||||
"binary-extensions": "^2.0.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
@@ -1641,7 +1642,7 @@
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
|
||||
|
||||
@@ -3493,19 +3494,19 @@
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="],
|
||||
"turbo": ["turbo@2.8.12", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.12", "turbo-darwin-arm64": "2.8.12", "turbo-linux-64": "2.8.12", "turbo-linux-arm64": "2.8.12", "turbo-windows-64": "2.8.12", "turbo-windows-arm64": "2.8.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-auUAMLmi0eJhxDhQrxzvuhfEbICnVt0CTiYQYY8WyRJ5nwCDZxD0JG8bCSxT4nusI2CwJzmZAay5BfF6LmK7Hw=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-EiHJmW2MeQQx+21x8hjMHw/uPhXt9PIxvDrxzOtyVwrXzL0tQmsxtO4qHf2l7uA+K6PUJ4+TjY1MHZDuCvWXrw=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cbqqGN0vd7ly2TeuaM8k9AK9u1CABO4kBA5KPSqovTiLL3sORccn/mZzJSbvQf0EsYRfU34MgW5FotfwW3kx8Q=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jXKw9j4r4q6s0goSXuKI3aKbQK2qiNeP25lGGEnq018TM6SWRW1CCpPMxyG91aCKrub7wDm/K45sGNT4ZFBcFQ=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-BRJCMdyXjyBoL0GYpvj9d2WNfMHwc3tKmJG5ATn2Efvil9LsiOsd/93/NxDqW0jACtHFNVOPnd/CBwXRPiRbwA=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.12", "", { "os": "win32", "cpu": "x64" }, "sha512-vyFOlpFFzQFkikvSVhVkESEfzIopgs2J7J1rYvtSwSHQ4zmHxkC95Q8Kjkus8gg+8X2mZyP1GS5jirmaypGiPw=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-9nRnlw5DF0LkJClkIws1evaIF36dmmMEO84J5Uj4oQ8C0QTHwlH7DNe5Kq2Jdmu8GXESCNDNuUYG8Cx6W/vm3g=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
@@ -3825,6 +3826,8 @@
|
||||
|
||||
"@langchain/core/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"@mdx-js/mdx/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
|
||||
@@ -4079,6 +4082,8 @@
|
||||
|
||||
"engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
@@ -4139,6 +4144,8 @@
|
||||
|
||||
"imapflow/pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="],
|
||||
|
||||
"import-in-the-middle/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"inquirer/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
|
||||
@@ -4173,8 +4180,12 @@
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"neo4j-driver-bolt-connection/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"glob": "13.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.8.13"
|
||||
"turbo": "2.8.12"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,scss}": [
|
||||
|
||||
Reference in New Issue
Block a user