diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md index a2857f73d..c83a95b1b 100644 --- a/.claude/commands/add-tools.md +++ b/.claude/commands/add-tools.md @@ -55,21 +55,21 @@ export const {serviceName}{Action}Tool: ToolConfig< }, params: { - // Hidden params (system-injected) + // Hidden params (system-injected, only use hidden for oauth accessToken) accessToken: { type: 'string', required: true, visibility: 'hidden', description: 'OAuth access token', }, - // User-only params (credentials, IDs user must provide) + // User-only params (credentials, api key, IDs user must provide) someId: { type: 'string', required: true, visibility: 'user-only', description: 'The ID of the resource', }, - // User-or-LLM params (can be provided by user OR computed by LLM) + // User-or-LLM params (everything else, can be provided by user OR computed by LLM) query: { type: 'string', required: false, // Use false for optional @@ -114,8 +114,8 @@ export const {serviceName}{Action}Tool: ToolConfig< ### Visibility Options - `'hidden'` - System-injected (OAuth tokens, internal params). User never sees. -- `'user-only'` - User must provide (credentials, account-specific IDs) -- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters) +- `'user-only'` - User must provide (credentials, api keys, account-specific IDs) +- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters, most fall into this category) ### Parameter Types - `'string'` - Text values diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 9c901860a..f9b47f43c 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react' +import { useId } from 'react' export function SearchIcon(props: SVGProps) { return ( @@ -737,6 +738,9 @@ export function GmailIcon(props: SVGProps) { } export function GrafanaIcon(props: SVGProps) { + const id = useId() + const gradientId = `grafana_gradient_${id}` + return ( ) { fill='none' > ) { } export function SupabaseIcon(props: SVGProps) { + const id = useId() + const gradient0 = `supabase_paint0_${id}` + const gradient1 = `supabase_paint1_${id}` + return ( ) { > ) { /> ) { ) { } export function MistralIcon(props: SVGProps) { + const id = useId() + const clipId = `mistral_clip_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMidYMid meet' > - + ) { /> - + @@ -2126,6 +2137,9 @@ export function MicrosoftIcon(props: SVGProps) { } export function MicrosoftTeamsIcon(props: SVGProps) { + const id = useId() + const gradientId = `msteams_gradient_${id}` + return ( ) { d='M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z' /> ) { ) { } export function OutlookIcon(props: SVGProps) { + const id = useId() + const gradient1 = `outlook_gradient1_${id}` + const gradient2 = `outlook_gradient2_${id}` + return ( ) { ) { ) { d='M936.833,461.305v823.136c-0.046,43.067-34.861,78.015-77.927,78.225H425.833 V383.25h433.072c43.062,0.023,77.951,34.951,77.927,78.013C936.833,461.277,936.833,461.291,936.833,461.305z' /> ) { ) { } export function MicrosoftExcelIcon(props: SVGProps) { + const id = useId() + const gradientId = `excel_gradient_${id}` + return ( ) { d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z' /> ) { ) => ( ) -export const AzureIcon = (props: SVGProps) => ( - - - - - - - - - - - - - - - - - - - - - - - -) +export function AzureIcon(props: SVGProps) { + const id = useId() + const gradient0 = `azure_paint0_${id}` + const gradient1 = `azure_paint1_${id}` + const gradient2 = `azure_paint2_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} export const GroqIcon = (props: SVGProps) => ( ) => ( ) -export const GeminiIcon = (props: SVGProps) => ( - - Gemini - - - - - - - - - -) +export function GeminiIcon(props: SVGProps) { + const id = useId() + const gradientId = `gemini_gradient_${id}` + + return ( + + Gemini + + + + + + + + + + ) +} export const VertexIcon = (props: SVGProps) => ( ) { } export function QdrantIcon(props: SVGProps) { + const id = useId() + const gradientId = `qdrant_gradient_${id}` + const clipPathId = `qdrant_clippath_${id}` + return ( - + ) { /> ) { - + @@ -3254,28 +3291,33 @@ export const SOC2BadgeIcon = (props: SVGProps) => ( ) -export const HIPAABadgeIcon = (props: SVGProps) => ( - - - - - - - - - - - - -) +export function HIPAABadgeIcon(props: SVGProps) { + const id = useId() + const clipId = `hipaa_clip_${id}` + + return ( + + + + + + + + + + + + + ) +} export function GoogleFormsIcon(props: SVGProps) { return ( @@ -3292,19 +3334,6 @@ export function GoogleFormsIcon(props: SVGProps) { d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z' fill='#F1F1F1' /> - - - - - - ) } @@ -3753,6 +3782,9 @@ export function SentryIcon(props: SVGProps) { } export function IncidentioIcon(props: SVGProps) { + const id = useId() + const clipId = `incidentio_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -3995,6 +4027,9 @@ export function SftpIcon(props: SVGProps) { } export function ApifyIcon(props: SVGProps) { + const id = useId() + const clipId = `apify_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4128,6 +4163,9 @@ export function TextractIcon(props: SVGProps) { } export function McpIcon(props: SVGProps) { + const id = useId() + const clipId = `mcp_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4478,6 +4516,10 @@ export function GrainIcon(props: SVGProps) { } export function CirclebackIcon(props: SVGProps) { + const id = useId() + const patternId = `circleback_pattern_${id}` + const imageId = `circleback_image_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' xmlnsXlink='http://www.w3.org/1999/xlink' > - + - - + + ) { } export function FirefliesIcon(props: SVGProps) { + const id = useId() + const g1 = `fireflies_g1_${id}` + const g2 = `fireflies_g2_${id}` + const g3 = `fireflies_g3_${id}` + const g4 = `fireflies_g4_${id}` + const g5 = `fireflies_g5_${id}` + const g6 = `fireflies_g6_${id}` + const g7 = `fireflies_g7_${id}` + const g8 = `fireflies_g8_${id}` + return ( ) { ) { ) { ) { ) { ) { ) { ) { - - + + - - + + @@ -4695,10 +4747,13 @@ export function FirefliesIcon(props: SVGProps) { } export function BedrockIcon(props: SVGProps) { + const id = useId() + const gradientId = `bedrock_gradient_${id}` + return ( - + @@ -4706,7 +4761,7 @@ export function BedrockIcon(props: SVGProps) { diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 06e2c3313..a55c23da1 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -35,8 +35,7 @@ const AutoLayoutRequestSchema = z.object({ }) .optional() .default({}), - // Optional: if provided, use these blocks instead of loading from DB - // This allows using blocks with live measurements from the UI + gridSize: z.number().min(0).max(50).optional(), blocks: z.record(z.any()).optional(), edges: z.array(z.any()).optional(), loops: z.record(z.any()).optional(), @@ -53,7 +52,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id: workflowId } = await params try { - // Get the session const session = await getSession() if (!session?.user?.id) { logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) @@ -62,7 +60,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const userId = session.user.id - // Parse request body const body = await request.json() const layoutOptions = AutoLayoutRequestSchema.parse(body) @@ -70,7 +67,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId, }) - // Fetch the workflow to check ownership/access const accessContext = await getWorkflowAccessContext(workflowId, userId) const workflowData = accessContext?.workflow @@ -79,7 +75,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - // Check if user has permission to update this workflow const canUpdate = accessContext?.isOwner || (workflowData.workspaceId @@ -94,8 +89,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Use provided blocks/edges if available (with live measurements from UI), - // otherwise load from database let currentWorkflowData: NormalizedWorkflowData | null if (layoutOptions.blocks && layoutOptions.edges) { @@ -125,6 +118,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ y: layoutOptions.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, }, alignment: layoutOptions.alignment, + gridSize: layoutOptions.gridSize, } const layoutResult = applyAutoLayout( diff --git a/apps/sim/app/api/yaml/autolayout/route.ts b/apps/sim/app/api/yaml/autolayout/route.ts deleted file mode 100644 index 600212340..000000000 --- a/apps/sim/app/api/yaml/autolayout/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { generateRequestId } from '@/lib/core/utils/request' -import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { - DEFAULT_HORIZONTAL_SPACING, - DEFAULT_LAYOUT_PADDING, - DEFAULT_VERTICAL_SPACING, -} from '@/lib/workflows/autolayout/constants' - -const logger = createLogger('YamlAutoLayoutAPI') - -const AutoLayoutRequestSchema = z.object({ - workflowState: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - loops: z.record(z.any()).optional().default({}), - parallels: z.record(z.any()).optional().default({}), - }), - options: z - .object({ - spacing: z - .object({ - horizontal: z.number().optional(), - vertical: z.number().optional(), - }) - .optional(), - alignment: z.enum(['start', 'center', 'end']).optional(), - padding: z - .object({ - x: z.number().optional(), - y: z.number().optional(), - }) - .optional(), - }) - .optional(), -}) - -export async function POST(request: NextRequest) { - const requestId = generateRequestId() - - try { - const body = await request.json() - const { workflowState, options } = AutoLayoutRequestSchema.parse(body) - - logger.info(`[${requestId}] Applying auto layout`, { - blockCount: Object.keys(workflowState.blocks).length, - edgeCount: workflowState.edges.length, - }) - - const autoLayoutOptions = { - horizontalSpacing: options?.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING, - verticalSpacing: options?.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING, - padding: { - x: options?.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, - y: options?.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, - }, - alignment: options?.alignment ?? 'center', - } - - const layoutResult = applyAutoLayout( - workflowState.blocks, - workflowState.edges, - autoLayoutOptions - ) - - if (!layoutResult.success || !layoutResult.blocks) { - logger.error(`[${requestId}] Auto layout failed:`, { - error: layoutResult.error, - }) - return NextResponse.json( - { - success: false, - errors: [layoutResult.error || 'Unknown auto layout error'], - }, - { status: 500 } - ) - } - - logger.info(`[${requestId}] Auto layout completed successfully:`, { - success: true, - blockCount: Object.keys(layoutResult.blocks).length, - }) - - const transformedResponse = { - success: true, - workflowState: { - blocks: layoutResult.blocks, - edges: workflowState.edges, - loops: workflowState.loops || {}, - parallels: workflowState.parallels || {}, - }, - } - - return NextResponse.json(transformedResponse) - } catch (error) { - logger.error(`[${requestId}] Auto layout failed:`, error) - - return NextResponse.json( - { - success: false, - errors: [error instanceof Error ? error.message : 'Unknown auto layout error'], - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout.ts index c972fe86c..3bcb0676b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { useReactFlow } from 'reactflow' import type { AutoLayoutOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils' import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils' +import { useSnapToGridSize } from '@/hooks/queries/general-settings' import { useCanvasViewport } from '@/hooks/use-canvas-viewport' export type { AutoLayoutOptions } @@ -13,21 +14,28 @@ const logger = createLogger('useAutoLayout') * Hook providing auto-layout functionality for workflows. * Binds workflowId context and provides memoized callback for React components. * Includes automatic fitView animation after successful layout. + * Automatically uses the user's snap-to-grid setting for grid-aligned layout. * * Note: This hook requires a ReactFlowProvider ancestor. */ export function useAutoLayout(workflowId: string | null) { const reactFlowInstance = useReactFlow() const { fitViewToBounds } = useCanvasViewport(reactFlowInstance) + const snapToGridSize = useSnapToGridSize() const applyAutoLayoutAndUpdateStore = useCallback( async (options: AutoLayoutOptions = {}) => { if (!workflowId) { return { success: false, error: 'No workflow ID provided' } } - return applyAutoLayoutStandalone(workflowId, options) + // Include gridSize from user's snap-to-grid setting + const optionsWithGrid: AutoLayoutOptions = { + ...options, + gridSize: options.gridSize ?? (snapToGridSize > 0 ? snapToGridSize : undefined), + } + return applyAutoLayoutStandalone(workflowId, optionsWithGrid) }, - [workflowId] + [workflowId, snapToGridSize] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts index e45df1ba6..2be615c8d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts @@ -21,6 +21,7 @@ export interface AutoLayoutOptions { x?: number y?: number } + gridSize?: number } /** @@ -62,6 +63,7 @@ export async function applyAutoLayoutAndUpdateStore( x: options.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, y: options.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, }, + gridSize: options.gridSize, } // Call the autolayout API route diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index a9aa5ba8f..07c8c5468 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2352,33 +2352,12 @@ const WorkflowContent = React.memo(() => { window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) - /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ - const onNodesChange = useCallback( - (changes: NodeChange[]) => { - selectedIdsRef.current = null - setDisplayNodes((nds) => { - const updated = applyNodeChanges(changes, nds) - const hasSelectionChange = changes.some((c) => c.type === 'select') - if (!hasSelectionChange) return updated - const resolved = resolveParentChildSelectionConflicts(updated, blocks) - selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id) - return resolved - }) - const selectedIds = selectedIdsRef.current as string[] | null - if (selectedIds !== null) { - syncPanelWithSelection(selectedIds) - } - }, - [blocks] - ) - /** - * Updates container dimensions in displayNodes during drag. - * This allows live resizing of containers as their children are dragged. + * Updates container dimensions in displayNodes during drag or keyboard movement. */ - const updateContainerDimensionsDuringDrag = useCallback( - (draggedNodeId: string, draggedNodePosition: { x: number; y: number }) => { - const parentId = blocks[draggedNodeId]?.data?.parentId + const updateContainerDimensionsDuringMove = useCallback( + (movedNodeId: string, movedNodePosition: { x: number; y: number }) => { + const parentId = blocks[movedNodeId]?.data?.parentId if (!parentId) return setDisplayNodes((currentNodes) => { @@ -2386,7 +2365,7 @@ const WorkflowContent = React.memo(() => { if (childNodes.length === 0) return currentNodes const childPositions = childNodes.map((node) => { - const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position + const nodePosition = node.id === movedNodeId ? movedNodePosition : node.position const { width, height } = getBlockDimensions(node.id) return { x: nodePosition.x, y: nodePosition.y, width, height } }) @@ -2417,6 +2396,55 @@ const WorkflowContent = React.memo(() => { [blocks, getBlockDimensions] ) + /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + selectedIdsRef.current = null + setDisplayNodes((nds) => { + const updated = applyNodeChanges(changes, nds) + const hasSelectionChange = changes.some((c) => c.type === 'select') + if (!hasSelectionChange) return updated + const resolved = resolveParentChildSelectionConflicts(updated, blocks) + selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id) + return resolved + }) + const selectedIds = selectedIdsRef.current as string[] | null + if (selectedIds !== null) { + syncPanelWithSelection(selectedIds) + } + + // Handle position changes (e.g., from keyboard arrow key movement) + // Update container dimensions when child nodes are moved and persist to backend + // Only persist if not in a drag operation (drag-end is handled by onNodeDragStop) + const isInDragOperation = + getDragStartPosition() !== null || multiNodeDragStartRef.current.size > 0 + const keyboardPositionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + for (const change of changes) { + if ( + change.type === 'position' && + !change.dragging && + 'position' in change && + change.position + ) { + updateContainerDimensionsDuringMove(change.id, change.position) + if (!isInDragOperation) { + keyboardPositionUpdates.push({ id: change.id, position: change.position }) + } + } + } + // Persist keyboard movements to backend for collaboration sync + if (keyboardPositionUpdates.length > 0) { + collaborativeBatchUpdatePositions(keyboardPositionUpdates) + } + }, + [ + blocks, + updateContainerDimensionsDuringMove, + collaborativeBatchUpdatePositions, + getDragStartPosition, + ] + ) + /** * Effect to resize loops when nodes change (add/remove/position change). * Runs on structural changes only - not during drag (position-only changes). @@ -2661,7 +2689,7 @@ const WorkflowContent = React.memo(() => { // If the node is inside a container, update container dimensions during drag if (currentParentId) { - updateContainerDimensionsDuringDrag(node.id, node.position) + updateContainerDimensionsDuringMove(node.id, node.position) } // Check if this is a starter block - starter blocks should never be in containers @@ -2778,7 +2806,7 @@ const WorkflowContent = React.memo(() => { blocks, getNodeAbsolutePosition, getNodeDepth, - updateContainerDimensionsDuringDrag, + updateContainerDimensionsDuringMove, highlightContainerNode, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index d18a40348..163eb49f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1,19 +1,22 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import * as DialogPrimitive from '@radix-ui/react-dialog' -import * as VisuallyHidden from '@radix-ui/react-visually-hidden' -import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Command } from 'cmdk' +import { Database, HelpCircle, Layout, Settings } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog' +import { createPortal } from 'react-dom' +import { Library } from '@/components/emcn' import { useBrandConfig } from '@/lib/branding/branding' import { cn } from '@/lib/core/utils/cn' -import { getToolOperationsIndex } from '@/lib/search/tool-operations' -import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { getAllBlocks } from '@/blocks' -import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchModalStore } from '@/stores/modals/search/store' +import type { + SearchBlockItem, + SearchDocItem, + SearchToolOperationItem, +} from '@/stores/modals/search/types' +import { useSettingsModalStore } from '@/stores/modals/settings/store' interface SearchModalProps { open: boolean @@ -38,277 +41,49 @@ interface WorkspaceItem { isCurrent?: boolean } -interface BlockItem { - id: string - name: string - description: string - icon: React.ComponentType - bgColor: string - type: string - config?: any -} - -interface ToolItem { - id: string - name: string - description: string - icon: React.ComponentType - bgColor: string - type: string -} - interface PageItem { id: string name: string - icon: React.ComponentType - href: string - shortcut?: string -} - -interface DocItem { - id: string - name: string - icon: React.ComponentType - href: string - type: 'main' | 'block' | 'tool' -} - -type SearchItem = { - id: string - name: string - description?: string - icon?: React.ComponentType - bgColor?: string - color?: string + icon: React.ComponentType<{ className?: string }> href?: string + onClick?: () => void shortcut?: string - type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc' - isCurrent?: boolean - blockType?: string - config?: any - operationId?: string - aliases?: string[] } -interface SearchResultItemProps { - item: SearchItem - visualIndex: number - isSelected: boolean - onItemClick: (item: SearchItem) => void -} - -const SearchResultItem = memo(function SearchResultItem({ - item, - visualIndex, - isSelected, - onItemClick, -}: SearchResultItemProps) { - const Icon = item.icon - const showColoredIcon = - item.type === 'block' || - item.type === 'trigger' || - item.type === 'tool' || - item.type === 'tool-operation' - const isWorkflow = item.type === 'workflow' - const isWorkspace = item.type === 'workspace' - - const handleClick = useCallback(() => { - onItemClick(item) - }, [onItemClick, item]) - - return ( -
- ) : ( - Icon && ( -
- -
- ) - )} - - )} - - {/* Content */} - - {item.name} - {item.isCurrent && ' (current)'} - - - {/* Shortcut */} - {item.shortcut && ( - - {item.shortcut} - - )} - - ) -}) - -export const SearchModal = memo(function SearchModal({ +export function SearchModal({ open, onOpenChange, workflows = [], workspaces = [], isOnWorkflowPage = false, }: SearchModalProps) { - const [searchQuery, setSearchQuery] = useState('') - const [selectedIndex, setSelectedIndex] = useState(0) const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const brand = useBrandConfig() - const { filterBlocks } = usePermissionConfig() + const inputRef = useRef(null) + const [search, setSearch] = useState('') + const [mounted, setMounted] = useState(false) + const openSettingsModal = useSettingsModalStore((state) => state.openModal) - const blocks = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] + useEffect(() => { + setMounted(true) + }, []) - const allBlocks = getAllBlocks() - const filteredAllBlocks = filterBlocks(allBlocks) - const regularBlocks = filteredAllBlocks - .filter( - (block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks' - ) - .map( - (block): BlockItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - }) - ) + const { blocks, tools, triggers, toolOperations, docs } = useSearchModalStore( + (state) => state.data + ) - const specialBlocks: BlockItem[] = [ - { - id: 'loop', - name: 'Loop', - description: 'Create a Loop', - icon: RepeatIcon, - bgColor: '#2FB3FF', - type: 'loop', - }, - { - id: 'parallel', - name: 'Parallel', - description: 'Parallel Execution', - icon: SplitIcon, - bgColor: '#FEE12B', - type: 'parallel', - }, - ] - - return [...regularBlocks, ...filterBlocks(specialBlocks)] - }, [open, isOnWorkflowPage, filterBlocks]) - - const triggers = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allTriggers = getTriggersForSidebar() - const filteredTriggers = filterBlocks(allTriggers) - const priorityOrder = ['Start', 'Schedule', 'Webhook'] - - const sortedTriggers = filteredTriggers.sort((a, b) => { - const aIndex = priorityOrder.indexOf(a.name) - const bIndex = priorityOrder.indexOf(b.name) - const aHasPriority = aIndex !== -1 - const bHasPriority = bIndex !== -1 - - if (aHasPriority && bHasPriority) return aIndex - bIndex - if (aHasPriority) return -1 - if (bHasPriority) return 1 - return a.name.localeCompare(b.name) - }) - - return sortedTriggers.map( - (block): BlockItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - config: block, - }) - ) - }, [open, isOnWorkflowPage, filterBlocks]) - - const tools = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allBlocks = getAllBlocks() - const filteredAllBlocks = filterBlocks(allBlocks) - return filteredAllBlocks - .filter((block) => !block.hideFromToolbar && block.category === 'tools') - .map( - (block): ToolItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - }) - ) - }, [open, isOnWorkflowPage, filterBlocks]) - - const toolOperations = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allowedBlockTypes = new Set(tools.map((t) => t.type)) - - return getToolOperationsIndex() - .filter((op) => allowedBlockTypes.has(op.blockType)) - .map((op) => ({ - id: op.id, - name: `${op.serviceName}: ${op.operationName}`, - icon: op.icon, - bgColor: op.bgColor, - blockType: op.blockType, - operationId: op.operationId, - aliases: op.aliases, - })) - }, [open, isOnWorkflowPage, tools]) + const openHelpModal = useCallback(() => { + window.dispatchEvent(new CustomEvent('open-help-modal')) + }, []) const pages = useMemo( (): PageItem[] => [ { id: 'logs', name: 'Logs', - icon: ScrollText, + icon: Library, href: `/workspace/${workspaceId}/logs`, shortcut: '⌘⇧L', }, @@ -319,398 +94,410 @@ export const SearchModal = memo(function SearchModal({ href: `/workspace/${workspaceId}/templates`, }, { - id: 'docs', - name: 'Docs', - icon: BookOpen, - href: brand.documentationUrl || 'https://docs.sim.ai/', + id: 'knowledge-base', + name: 'Knowledge Base', + icon: Database, + href: `/workspace/${workspaceId}/knowledge`, + }, + { + id: 'help', + name: 'Help', + icon: HelpCircle, + onClick: openHelpModal, + }, + { + id: 'settings', + name: 'Settings', + icon: Settings, + onClick: openSettingsModal, + shortcut: '⌘,', }, ], - [workspaceId, brand.documentationUrl] + [workspaceId, openHelpModal, openSettingsModal] ) - const docs = useMemo((): DocItem[] => { - if (!open) return [] + useEffect(() => { + if (open) { + setSearch('') + requestAnimationFrame(() => { + inputRef.current?.focus() + }) + } + }, [open]) - const allBlocks = getAllBlocks() - const docsItems: DocItem[] = [] + const handleSearchChange = useCallback((value: string) => { + setSearch(value) + requestAnimationFrame(() => { + const list = document.querySelector('[cmdk-list]') + if (list) { + list.scrollTop = 0 + } + }) + }, []) - allBlocks.forEach((block) => { - if (block.docsLink && !block.hideFromToolbar) { - docsItems.push({ - id: `docs-${block.type}`, - name: block.name, - icon: block.icon, - href: block.docsLink, - type: block.category === 'blocks' || block.category === 'triggers' ? 'block' : 'tool', + useEffect(() => { + if (!open) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onOpenChange(false) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, onOpenChange]) + + const handleBlockSelect = useCallback( + (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { + const enableTriggerMode = + type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: block.type, enableTriggerMode }, }) - } - }) - - return docsItems - }, [open]) - - const allItems = useMemo((): SearchItem[] => { - const items: SearchItem[] = [] - - workspaces.forEach((workspace) => { - items.push({ - id: workspace.id, - name: workspace.name, - href: workspace.href, - type: 'workspace', - isCurrent: workspace.isCurrent, - }) - }) - - workflows.forEach((workflow) => { - items.push({ - id: workflow.id, - name: workflow.name, - href: workflow.href, - type: 'workflow', - color: workflow.color, - isCurrent: workflow.isCurrent, - }) - }) - - pages.forEach((page) => { - items.push({ - id: page.id, - name: page.name, - icon: page.icon, - href: page.href, - shortcut: page.shortcut, - type: 'page', - }) - }) - - blocks.forEach((block) => { - items.push({ - id: block.id, - name: block.name, - description: block.description, - icon: block.icon, - bgColor: block.bgColor, - type: 'block', - blockType: block.type, - }) - }) - - triggers.forEach((trigger) => { - items.push({ - id: trigger.id, - name: trigger.name, - description: trigger.description, - icon: trigger.icon, - bgColor: trigger.bgColor, - type: 'trigger', - blockType: trigger.type, - config: trigger.config, - }) - }) - - tools.forEach((tool) => { - items.push({ - id: tool.id, - name: tool.name, - description: tool.description, - icon: tool.icon, - bgColor: tool.bgColor, - type: 'tool', - blockType: tool.type, - }) - }) - - toolOperations.forEach((op) => { - items.push({ - id: op.id, - name: op.name, - icon: op.icon, - bgColor: op.bgColor, - type: 'tool-operation', - blockType: op.blockType, - operationId: op.operationId, - aliases: op.aliases, - }) - }) - - docs.forEach((doc) => { - items.push({ - id: doc.id, - name: doc.name, - icon: doc.icon, - href: doc.href, - type: 'doc', - }) - }) - - return items - }, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs]) - - const sectionOrder = useMemo( - () => ['block', 'tool', 'trigger', 'doc', 'tool-operation', 'workflow', 'workspace', 'page'], - [] + ) + onOpenChange(false) + }, + [onOpenChange] ) - const filteredItems = useMemo(() => { - const orderMap = sectionOrder.reduce>( - (acc, type, index) => { - acc[type] = index - return acc - }, - {} as Record - ) + const handleToolOperationSelect = useCallback( + (op: SearchToolOperationItem) => { + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: op.blockType, presetOperation: op.operationId }, + }) + ) + onOpenChange(false) + }, + [onOpenChange] + ) - if (!searchQuery.trim()) { - return [...allItems].sort((a, b) => { - const aOrder = orderMap[a.type] ?? Number.MAX_SAFE_INTEGER - const bOrder = orderMap[b.type] ?? Number.MAX_SAFE_INTEGER - return aOrder - bOrder - }) - } - - const searchResults = searchItems(searchQuery, allItems) - - return searchResults - .sort((a, b) => { - if (a.score !== b.score) { - return b.score - a.score - } - - const aOrder = orderMap[a.item.type] ?? Number.MAX_SAFE_INTEGER - const bOrder = orderMap[b.item.type] ?? Number.MAX_SAFE_INTEGER - if (aOrder !== bOrder) { - return aOrder - bOrder - } - - return a.item.name.localeCompare(b.item.name) - }) - .map((result) => result.item) - }, [allItems, searchQuery, sectionOrder]) - - const groupedItems = useMemo(() => { - const groups: Record = { - workspace: [], - workflow: [], - page: [], - trigger: [], - block: [], - 'tool-operation': [], - tool: [], - doc: [], - } - - filteredItems.forEach((item) => { - if (groups[item.type]) { - groups[item.type].push(item) - } - }) - - return groups - }, [filteredItems]) - - const displayedItemsInVisualOrder = useMemo(() => { - const visualOrder: SearchItem[] = [] - - sectionOrder.forEach((type) => { - const items = groupedItems[type] || [] - items.forEach((item) => { - visualOrder.push(item) - }) - }) - - return visualOrder - }, [groupedItems, sectionOrder]) - - useEffect(() => { - setSelectedIndex(0) - }, [displayedItemsInVisualOrder]) - - useEffect(() => { - if (!open) { - setSearchQuery('') - setSelectedIndex(0) - } - }, [open]) - - const handleItemClick = useCallback( - (item: SearchItem) => { - switch (item.type) { - case 'block': - case 'trigger': - case 'tool': - if (item.blockType) { - const enableTriggerMode = - item.type === 'trigger' && item.config ? hasTriggerCapability(item.config) : false - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: item.blockType, - enableTriggerMode, - }, - }) - window.dispatchEvent(event) - } - break - case 'tool-operation': - if (item.blockType && item.operationId) { - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: item.blockType, - presetOperation: item.operationId, - }, - }) - window.dispatchEvent(event) - } - break - case 'workspace': - if (item.isCurrent) { - break - } - if (item.href) { - router.push(item.href) - } - break - case 'workflow': - if (!item.isCurrent && item.href) { - router.push(item.href) - window.dispatchEvent( - new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: item.id } }) - ) - } - break - case 'page': - case 'doc': - if (item.href) { - if (item.href.startsWith('http')) { - window.open(item.href, '_blank', 'noopener,noreferrer') - } else { - router.push(item.href) - } - } - break + const handleWorkflowSelect = useCallback( + (workflow: WorkflowItem) => { + if (!workflow.isCurrent && workflow.href) { + router.push(workflow.href) + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } }) + ) } onOpenChange(false) }, [router, onOpenChange] ) - useEffect(() => { - if (!open) return - - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - setSelectedIndex((prev) => Math.min(prev + 1, displayedItemsInVisualOrder.length - 1)) - break - case 'ArrowUp': - e.preventDefault() - setSelectedIndex((prev) => Math.max(prev - 1, 0)) - break - case 'Enter': - e.preventDefault() - if (displayedItemsInVisualOrder[selectedIndex]) { - handleItemClick(displayedItemsInVisualOrder[selectedIndex]) - } - break - case 'Escape': - e.preventDefault() - onOpenChange(false) - break + const handleWorkspaceSelect = useCallback( + (workspace: WorkspaceItem) => { + if (!workspace.isCurrent && workspace.href) { + router.push(workspace.href) } - } - - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [open, selectedIndex, displayedItemsInVisualOrder, handleItemClick, onOpenChange]) - - useEffect(() => { - if (open && selectedIndex >= 0) { - const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`) - if (element) { - element.scrollIntoView({ - block: 'nearest', - behavior: 'auto', - }) - } - } - }, [selectedIndex, open]) - - const sectionTitles: Record = { - workspace: 'Workspaces', - workflow: 'Workflows', - page: 'Pages', - trigger: 'Triggers', - block: 'Blocks', - 'tool-operation': 'Tool Operations', - tool: 'Tools', - doc: 'Docs', - } - - return ( - - - - - - Search - - - {/* Search input container */} -
- - setSearchQuery(e.target.value)} - className='w-full border-0 bg-transparent font-base text-[15px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none' - autoFocus - /> -
- - {/* Floating results container */} - {displayedItemsInVisualOrder.length > 0 ? ( -
- {sectionOrder.map((type) => { - const items = groupedItems[type] || [] - if (items.length === 0) return null - - return ( -
- {/* Section header */} -
- {sectionTitles[type]} -
- - {/* Section items */} -
- {items.map((item) => { - const visualIndex = displayedItemsInVisualOrder.indexOf(item) - return ( - - ) - })} -
-
- ) - })} -
- ) : searchQuery ? ( -
-

- No results found for "{searchQuery}" -

-
- ) : null} -
-
-
+ onOpenChange(false) + }, + [router, onOpenChange] ) -}) + + const handlePageSelect = useCallback( + (page: PageItem) => { + if (page.onClick) { + page.onClick() + } else if (page.href) { + if (page.href.startsWith('http')) { + window.open(page.href, '_blank', 'noopener,noreferrer') + } else { + router.push(page.href) + } + } + onOpenChange(false) + }, + [router, onOpenChange] + ) + + const handleDocSelect = useCallback( + (doc: SearchDocItem) => { + window.open(doc.href, '_blank', 'noopener,noreferrer') + onOpenChange(false) + }, + [onOpenChange] + ) + + const showBlocks = isOnWorkflowPage && blocks.length > 0 + const showTools = isOnWorkflowPage && tools.length > 0 + const showTriggers = isOnWorkflowPage && triggers.length > 0 + const showToolOperations = isOnWorkflowPage && toolOperations.length > 0 + const showDocs = isOnWorkflowPage && docs.length > 0 + + const customFilter = useCallback((value: string, search: string, keywords?: string[]) => { + const searchLower = search.toLowerCase() + const valueLower = value.toLowerCase() + + if (valueLower === searchLower) return 1 + if (valueLower.startsWith(searchLower)) return 0.8 + if (valueLower.includes(searchLower)) return 0.6 + + const searchWords = searchLower.split(/\s+/).filter(Boolean) + const allWordsMatch = searchWords.every((word) => valueLower.includes(word)) + if (allWordsMatch && searchWords.length > 0) return 0.4 + + if (keywords?.length) { + const keywordsLower = keywords.join(' ').toLowerCase() + if (keywordsLower.includes(searchLower)) return 0.3 + const keywordWordsMatch = searchWords.every((word) => keywordsLower.includes(word)) + if (keywordWordsMatch && searchWords.length > 0) return 0.2 + } + + return 0 + }, []) + + if (!mounted) return null + + return createPortal( + <> + {/* Overlay */} +
onOpenChange(false)} + aria-hidden={!open} + /> + + {/* Command palette - always rendered for instant opening, hidden with CSS */} +
+ + + + + No results found. + + + {showBlocks && ( + + {blocks.map((block) => ( + handleBlockSelect(block, 'block')} + icon={block.icon} + bgColor={block.bgColor} + showColoredIcon + > + {block.name} + + ))} + + )} + + {showTools && ( + + {tools.map((tool) => ( + handleBlockSelect(tool, 'tool')} + icon={tool.icon} + bgColor={tool.bgColor} + showColoredIcon + > + {tool.name} + + ))} + + )} + + {showTriggers && ( + + {triggers.map((trigger) => ( + handleBlockSelect(trigger, 'trigger')} + icon={trigger.icon} + bgColor={trigger.bgColor} + showColoredIcon + > + {trigger.name} + + ))} + + )} + + {workflows.length > 0 && ( + + {workflows.map((workflow) => ( + handleWorkflowSelect(workflow)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > +
+ + {workflow.name} + {workflow.isCurrent && ' (current)'} + + + ))} + + )} + + {showToolOperations && ( + + {toolOperations.map((op) => ( + handleToolOperationSelect(op)} + icon={op.icon} + bgColor={op.bgColor} + showColoredIcon + > + {op.name} + + ))} + + )} + + {workspaces.length > 0 && ( + + {workspaces.map((workspace) => ( + handleWorkspaceSelect(workspace)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > + + {workspace.name} + {workspace.isCurrent && ' (current)'} + + + ))} + + )} + + {showDocs && ( + + {docs.map((doc) => ( + handleDocSelect(doc)} + icon={doc.icon} + bgColor='#6B7280' + showColoredIcon + > + {doc.name} + + ))} + + )} + + {pages.length > 0 && ( + + {pages.map((page) => { + const Icon = page.icon + return ( + handlePageSelect(page)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > +
+ +
+ + {page.name} + + {page.shortcut && ( + + {page.shortcut} + + )} +
+ ) + })} +
+ )} + + +
+ , + document.body + ) +} + +const groupHeadingClassName = + '[&_[cmdk-group-heading]]:pt-[2px] [&_[cmdk-group-heading]]:pb-[4px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[13px] [&_[cmdk-group-heading]]:text-[var(--text-subtle)] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide' + +interface CommandItemProps { + value: string + keywords?: string[] + onSelect: () => void + icon: React.ComponentType<{ className?: string }> + bgColor: string + showColoredIcon?: boolean + children: React.ReactNode +} + +function CommandItem({ + value, + keywords, + onSelect, + icon: Icon, + bgColor, + showColoredIcon, + children, +}: CommandItemProps) { + return ( + +
+ +
+ + {children} + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts deleted file mode 100644 index 08525b16f..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Search utility functions for tiered matching algorithm - * Provides predictable search results prioritizing exact matches over fuzzy matches - */ - -export interface SearchableItem { - id: string - name: string - description?: string - type: string - aliases?: string[] - [key: string]: any -} - -export interface SearchResult { - item: T - score: number - matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description' -} - -const SCORE_EXACT_MATCH = 10000 -const SCORE_PREFIX_MATCH = 5000 -const SCORE_ALIAS_MATCH = 3000 -const SCORE_WORD_BOUNDARY = 1000 -const SCORE_SUBSTRING_MATCH = 100 -const DESCRIPTION_WEIGHT = 0.3 - -/** - * Calculate match score for a single field - * Returns 0 if no match found - */ -function calculateFieldScore( - query: string, - field: string -): { - score: number - matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | null -} { - const normalizedQuery = query.toLowerCase().trim() - const normalizedField = field.toLowerCase().trim() - - if (!normalizedQuery || !normalizedField) { - return { score: 0, matchType: null } - } - - // Tier 1: Exact match - if (normalizedField === normalizedQuery) { - return { score: SCORE_EXACT_MATCH, matchType: 'exact' } - } - - // Tier 2: Prefix match (starts with query) - if (normalizedField.startsWith(normalizedQuery)) { - return { score: SCORE_PREFIX_MATCH, matchType: 'prefix' } - } - - // Tier 3: Word boundary match (query matches start of a word) - const words = normalizedField.split(/[\s-_/]+/) - const hasWordBoundaryMatch = words.some((word) => word.startsWith(normalizedQuery)) - if (hasWordBoundaryMatch) { - return { score: SCORE_WORD_BOUNDARY, matchType: 'word-boundary' } - } - - // Tier 4: Substring match (query appears anywhere) - if (normalizedField.includes(normalizedQuery)) { - return { score: SCORE_SUBSTRING_MATCH, matchType: 'substring' } - } - - // No match - return { score: 0, matchType: null } -} - -/** - * Check if query matches any alias in the item's aliases array - * Returns the alias score if a match is found, 0 otherwise - */ -function calculateAliasScore( - query: string, - aliases?: string[] -): { score: number; matchType: 'alias' | null } { - if (!aliases || aliases.length === 0) { - return { score: 0, matchType: null } - } - - const normalizedQuery = query.toLowerCase().trim() - - for (const alias of aliases) { - const normalizedAlias = alias.toLowerCase().trim() - - if (normalizedAlias === normalizedQuery) { - return { score: SCORE_ALIAS_MATCH, matchType: 'alias' } - } - - if (normalizedAlias.startsWith(normalizedQuery)) { - return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' } - } - - if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) { - return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' } - } - } - - return { score: 0, matchType: null } -} - -/** - * Calculate multi-word match score - * Each word in the query must appear somewhere in the field - * Returns a score based on how well the words match - */ -function calculateMultiWordScore( - queryWords: string[], - field: string -): { score: number; matchType: 'word-boundary' | 'substring' | null } { - const normalizedField = field.toLowerCase().trim() - const fieldWords = normalizedField.split(/[\s\-_/:]+/) - - let allWordsMatch = true - let totalScore = 0 - let hasWordBoundary = false - - for (const queryWord of queryWords) { - const wordBoundaryMatch = fieldWords.some((fw) => fw.startsWith(queryWord)) - const substringMatch = normalizedField.includes(queryWord) - - if (wordBoundaryMatch) { - totalScore += SCORE_WORD_BOUNDARY - hasWordBoundary = true - } else if (substringMatch) { - totalScore += SCORE_SUBSTRING_MATCH - } else { - allWordsMatch = false - break - } - } - - if (!allWordsMatch) { - return { score: 0, matchType: null } - } - - return { - score: totalScore / queryWords.length, - matchType: hasWordBoundary ? 'word-boundary' : 'substring', - } -} - -/** - * Search items using tiered matching algorithm - * Returns items sorted by relevance (highest score first) - */ -export function searchItems( - query: string, - items: T[] -): SearchResult[] { - const normalizedQuery = query.trim() - - if (!normalizedQuery) { - return [] - } - - const results: SearchResult[] = [] - const queryWords = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean) - const isMultiWord = queryWords.length > 1 - - for (const item of items) { - const nameMatch = calculateFieldScore(normalizedQuery, item.name) - - const descMatch = item.description - ? calculateFieldScore(normalizedQuery, item.description) - : { score: 0, matchType: null } - - const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases) - - let nameScore = nameMatch.score - let descScore = descMatch.score * DESCRIPTION_WEIGHT - const aliasScore = aliasMatch.score - - let bestMatchType = nameMatch.matchType - - // For multi-word queries, also try matching each word independently and take the better score - if (isMultiWord) { - const multiWordNameMatch = calculateMultiWordScore(queryWords, item.name) - if (multiWordNameMatch.score > nameScore) { - nameScore = multiWordNameMatch.score - bestMatchType = multiWordNameMatch.matchType - } - - if (item.description) { - const multiWordDescMatch = calculateMultiWordScore(queryWords, item.description) - const multiWordDescScore = multiWordDescMatch.score * DESCRIPTION_WEIGHT - if (multiWordDescScore > descScore) { - descScore = multiWordDescScore - } - } - } - - const bestScore = Math.max(nameScore, descScore, aliasScore) - - if (bestScore > 0) { - let matchType: SearchResult['matchType'] = 'substring' - if (nameScore >= descScore && nameScore >= aliasScore) { - matchType = bestMatchType || 'substring' - } else if (aliasScore >= descScore) { - matchType = 'alias' - } else { - matchType = 'description' - } - - results.push({ - item, - score: bestScore, - matchType, - }) - } - } - - results.sort((a, b) => b.score - a.score) - - return results -} - -/** - * Get a human-readable match type label - */ -export function getMatchTypeLabel(matchType: SearchResult['matchType']): string { - switch (matchType) { - case 'exact': - return 'Exact match' - case 'prefix': - return 'Starts with' - case 'alias': - return 'Similar to' - case 'word-boundary': - return 'Word match' - case 'substring': - return 'Contains' - case 'description': - return 'In description' - default: - return 'Match' - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index e1f2ea3a7..d4103702b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -176,7 +176,7 @@ function FormattedInput({ onChange, onScroll, }: FormattedInputProps) { - const handleScroll = (e: React.UIEvent) => { + const handleScroll = (e: { currentTarget: HTMLInputElement }) => { onScroll(e.currentTarget.scrollLeft) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 5d5f36dc3..407e8b9ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -73,7 +73,12 @@ export const Sidebar = memo(function Sidebar() { const { data: sessionData, isPending: sessionLoading } = useSession() const { canEdit } = useUserPermissionsContext() - const { config: permissionConfig } = usePermissionConfig() + const { config: permissionConfig, filterBlocks } = usePermissionConfig() + const initializeSearchData = useSearchModalStore((state) => state.initializeData) + + useEffect(() => { + initializeSearchData(filterBlocks) + }, [initializeSearchData, filterBlocks]) /** * Sidebar state from store with hydration tracking to prevent SSR mismatch. diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 9c901860a..f9b47f43c 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react' +import { useId } from 'react' export function SearchIcon(props: SVGProps) { return ( @@ -737,6 +738,9 @@ export function GmailIcon(props: SVGProps) { } export function GrafanaIcon(props: SVGProps) { + const id = useId() + const gradientId = `grafana_gradient_${id}` + return ( ) { fill='none' > ) { } export function SupabaseIcon(props: SVGProps) { + const id = useId() + const gradient0 = `supabase_paint0_${id}` + const gradient1 = `supabase_paint1_${id}` + return ( ) { > ) { /> ) { ) { } export function MistralIcon(props: SVGProps) { + const id = useId() + const clipId = `mistral_clip_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMidYMid meet' > - + ) { /> - + @@ -2126,6 +2137,9 @@ export function MicrosoftIcon(props: SVGProps) { } export function MicrosoftTeamsIcon(props: SVGProps) { + const id = useId() + const gradientId = `msteams_gradient_${id}` + return ( ) { d='M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z' /> ) { ) { } export function OutlookIcon(props: SVGProps) { + const id = useId() + const gradient1 = `outlook_gradient1_${id}` + const gradient2 = `outlook_gradient2_${id}` + return ( ) { ) { ) { d='M936.833,461.305v823.136c-0.046,43.067-34.861,78.015-77.927,78.225H425.833 V383.25h433.072c43.062,0.023,77.951,34.951,77.927,78.013C936.833,461.277,936.833,461.291,936.833,461.305z' /> ) { ) { } export function MicrosoftExcelIcon(props: SVGProps) { + const id = useId() + const gradientId = `excel_gradient_${id}` + return ( ) { d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z' /> ) { ) => ( ) -export const AzureIcon = (props: SVGProps) => ( - - - - - - - - - - - - - - - - - - - - - - - -) +export function AzureIcon(props: SVGProps) { + const id = useId() + const gradient0 = `azure_paint0_${id}` + const gradient1 = `azure_paint1_${id}` + const gradient2 = `azure_paint2_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} export const GroqIcon = (props: SVGProps) => ( ) => ( ) -export const GeminiIcon = (props: SVGProps) => ( - - Gemini - - - - - - - - - -) +export function GeminiIcon(props: SVGProps) { + const id = useId() + const gradientId = `gemini_gradient_${id}` + + return ( + + Gemini + + + + + + + + + + ) +} export const VertexIcon = (props: SVGProps) => ( ) { } export function QdrantIcon(props: SVGProps) { + const id = useId() + const gradientId = `qdrant_gradient_${id}` + const clipPathId = `qdrant_clippath_${id}` + return ( - + ) { /> ) { - + @@ -3254,28 +3291,33 @@ export const SOC2BadgeIcon = (props: SVGProps) => ( ) -export const HIPAABadgeIcon = (props: SVGProps) => ( - - - - - - - - - - - - -) +export function HIPAABadgeIcon(props: SVGProps) { + const id = useId() + const clipId = `hipaa_clip_${id}` + + return ( + + + + + + + + + + + + + ) +} export function GoogleFormsIcon(props: SVGProps) { return ( @@ -3292,19 +3334,6 @@ export function GoogleFormsIcon(props: SVGProps) { d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z' fill='#F1F1F1' /> - - - - - - ) } @@ -3753,6 +3782,9 @@ export function SentryIcon(props: SVGProps) { } export function IncidentioIcon(props: SVGProps) { + const id = useId() + const clipId = `incidentio_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -3995,6 +4027,9 @@ export function SftpIcon(props: SVGProps) { } export function ApifyIcon(props: SVGProps) { + const id = useId() + const clipId = `apify_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4128,6 +4163,9 @@ export function TextractIcon(props: SVGProps) { } export function McpIcon(props: SVGProps) { + const id = useId() + const clipId = `mcp_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4478,6 +4516,10 @@ export function GrainIcon(props: SVGProps) { } export function CirclebackIcon(props: SVGProps) { + const id = useId() + const patternId = `circleback_pattern_${id}` + const imageId = `circleback_image_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' xmlnsXlink='http://www.w3.org/1999/xlink' > - + - - + + ) { } export function FirefliesIcon(props: SVGProps) { + const id = useId() + const g1 = `fireflies_g1_${id}` + const g2 = `fireflies_g2_${id}` + const g3 = `fireflies_g3_${id}` + const g4 = `fireflies_g4_${id}` + const g5 = `fireflies_g5_${id}` + const g6 = `fireflies_g6_${id}` + const g7 = `fireflies_g7_${id}` + const g8 = `fireflies_g8_${id}` + return ( ) { ) { ) { ) { ) { ) { ) { ) { - - + + - - + + @@ -4695,10 +4747,13 @@ export function FirefliesIcon(props: SVGProps) { } export function BedrockIcon(props: SVGProps) { + const id = useId() + const gradientId = `bedrock_gradient_${id}` + return ( - + @@ -4706,7 +4761,7 @@ export function BedrockIcon(props: SVGProps) { diff --git a/apps/sim/executor/dag/construction/edges.ts b/apps/sim/executor/dag/construction/edges.ts index 4a36e5f91..ef6c238de 100644 --- a/apps/sim/executor/dag/construction/edges.ts +++ b/apps/sim/executor/dag/construction/edges.ts @@ -207,6 +207,7 @@ export class EdgeConstructor { for (const connection of workflow.connections) { let { source, target } = connection const originalSource = source + const originalTarget = target let sourceHandle = this.generateSourceHandle( source, target, @@ -257,14 +258,14 @@ export class EdgeConstructor { target = sentinelStartId } - if (loopSentinelStartId) { - this.addEdge(dag, loopSentinelStartId, target, EDGE.LOOP_EXIT, targetHandle) - } - if (this.edgeCrossesLoopBoundary(source, target, blocksInLoops, dag)) { continue } + if (loopSentinelStartId && !blocksInLoops.has(originalTarget)) { + this.addEdge(dag, loopSentinelStartId, target, EDGE.LOOP_EXIT, targetHandle) + } + if (!this.isEdgeReachable(source, target, reachableBlocks, dag)) { continue } diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index f159e4db0..e73f57323 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -28,6 +28,7 @@ import type { } from '@/executor/types' import { streamingResponseFormatProcessor } from '@/executor/utils' import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors' +import { isJSONString } from '@/executor/utils/json' import { filterOutputForLog } from '@/executor/utils/output-filter' import { validateBlockType } from '@/executor/utils/permission-check' import type { VariableResolver } from '@/executor/variables/resolver' @@ -86,7 +87,7 @@ export class BlockExecutor { resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) if (blockLog) { - blockLog.input = resolvedInputs + blockLog.input = this.parseJsonInputs(resolvedInputs) } } catch (error) { cleanupSelfReference?.() @@ -157,7 +158,14 @@ export class BlockExecutor { const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block, }) - this.callOnBlockComplete(ctx, node, block, resolvedInputs, displayOutput, duration) + this.callOnBlockComplete( + ctx, + node, + block, + this.parseJsonInputs(resolvedInputs), + displayOutput, + duration + ) } return normalizedOutput @@ -233,7 +241,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage - blockLog.input = input + blockLog.input = this.parseJsonInputs(input) blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) } @@ -248,7 +256,14 @@ export class BlockExecutor { if (!isSentinel) { const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) - this.callOnBlockComplete(ctx, node, block, input, displayOutput, duration) + this.callOnBlockComplete( + ctx, + node, + block, + this.parseJsonInputs(input), + displayOutput, + duration + ) } const hasErrorPort = this.hasErrorPortEdge(node) @@ -336,6 +351,36 @@ export class BlockExecutor { return { result: output } } + /** + * Parse JSON string inputs to objects for log display only. + * Attempts to parse any string that looks like JSON. + * Returns a new object - does not mutate the original inputs. + */ + private parseJsonInputs(inputs: Record): Record { + let result = inputs + let hasChanges = false + + for (const [key, value] of Object.entries(inputs)) { + // isJSONString is a quick heuristic (checks for { or [), not a validator. + // Invalid JSON is safely caught below - this just avoids JSON.parse on every string. + if (typeof value !== 'string' || !isJSONString(value)) { + continue + } + + try { + if (!hasChanges) { + result = { ...inputs } + hasChanges = true + } + result[key] = JSON.parse(value.trim()) + } catch { + // Not valid JSON, keep original string + } + } + + return result + } + private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void { const blockId = node.id const blockName = block.metadata?.name ?? blockId diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index f0ac33fa7..3598bed7d 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -77,15 +77,16 @@ export class EdgeManager { } } - // Check if any deactivation targets that previously received an activated edge are now ready - for (const { target } of edgesToDeactivate) { - if ( - !readyNodes.includes(target) && - !activatedTargets.includes(target) && - this.nodesWithActivatedEdge.has(target) && - this.isTargetReady(target) - ) { - readyNodes.push(target) + if (output.selectedRoute !== EDGE.LOOP_EXIT && output.selectedRoute !== EDGE.PARALLEL_EXIT) { + for (const { target } of edgesToDeactivate) { + if ( + !readyNodes.includes(target) && + !activatedTargets.includes(target) && + this.nodesWithActivatedEdge.has(target) && + this.isTargetReady(target) + ) { + readyNodes.push(target) + } } } diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 94c7e37a9..92b43e6a5 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -412,6 +412,12 @@ export class ExecutionEngine { logger.info('Processing outgoing edges', { nodeId, outgoingEdgesCount: node.outgoingEdges.size, + outgoingEdges: Array.from(node.outgoingEdges.entries()).map(([id, e]) => ({ + id, + target: e.target, + sourceHandle: e.sourceHandle, + })), + output, readyNodesCount: readyNodes.length, readyNodes, }) diff --git a/apps/sim/executor/execution/state.ts b/apps/sim/executor/execution/state.ts index 7cf849c9e..bbbc7bc42 100644 --- a/apps/sim/executor/execution/state.ts +++ b/apps/sim/executor/execution/state.ts @@ -27,6 +27,8 @@ export interface ParallelScope { items?: any[] /** Error message if parallel validation failed (e.g., exceeded max branches) */ validationError?: string + /** Whether the parallel has an empty distribution and should be skipped */ + isEmpty?: boolean } export class ExecutionState implements BlockStateController { diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 6c0d19fc3..007833d9c 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -936,8 +936,12 @@ export class AgentBlockHandler implements BlockHandler { systemPrompt: validMessages ? undefined : inputs.systemPrompt, context: validMessages ? undefined : stringifyJSON(messages), tools: formattedTools, - temperature: inputs.temperature, - maxTokens: inputs.maxTokens, + temperature: + inputs.temperature != null && inputs.temperature !== '' + ? Number(inputs.temperature) + : undefined, + maxTokens: + inputs.maxTokens != null && inputs.maxTokens !== '' ? Number(inputs.maxTokens) : undefined, apiKey: inputs.apiKey, azureEndpoint: inputs.azureEndpoint, azureApiVersion: inputs.azureApiVersion, diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index c3050f3a0..411b02a27 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -14,8 +14,8 @@ export interface AgentInputs { slidingWindowSize?: string // For message-based sliding window slidingWindowTokens?: string // For token-based sliding window // LLM parameters - temperature?: number - maxTokens?: number + temperature?: string + maxTokens?: string apiKey?: string azureEndpoint?: string azureApiVersion?: string diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 2cce72272..a2bd7babf 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -395,10 +395,10 @@ export class LoopOrchestrator { return true } - // forEach: skip if items array is empty if (scope.loopType === 'forEach') { if (!scope.items || scope.items.length === 0) { - logger.info('ForEach loop has empty items, skipping loop body', { loopId }) + logger.info('ForEach loop has empty collection, skipping loop body', { loopId }) + this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME) return false } return true @@ -408,6 +408,8 @@ export class LoopOrchestrator { if (scope.loopType === 'for') { if (scope.maxIterations === 0) { logger.info('For loop has 0 iterations, skipping loop body', { loopId }) + // Set empty output for the loop + this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME) return false } return true diff --git a/apps/sim/executor/orchestrators/node.ts b/apps/sim/executor/orchestrators/node.ts index be7698b50..535693a82 100644 --- a/apps/sim/executor/orchestrators/node.ts +++ b/apps/sim/executor/orchestrators/node.ts @@ -108,7 +108,7 @@ export class NodeExecutionOrchestrator { if (loopId) { const shouldExecute = await this.loopOrchestrator.evaluateInitialCondition(ctx, loopId) if (!shouldExecute) { - logger.info('While loop initial condition false, skipping loop body', { loopId }) + logger.info('Loop initial condition false, skipping loop body', { loopId }) return { sentinelStart: true, shouldExit: true, @@ -169,6 +169,17 @@ export class NodeExecutionOrchestrator { this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel) } } + + const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId) + if (scope?.isEmpty) { + logger.info('Parallel has empty distribution, skipping parallel body', { parallelId }) + return { + sentinelStart: true, + shouldExit: true, + selectedRoute: EDGE.PARALLEL_EXIT, + } + } + return { sentinelStart: true } } diff --git a/apps/sim/executor/orchestrators/parallel.ts b/apps/sim/executor/orchestrators/parallel.ts index 517d3f6ff..b2b67428f 100644 --- a/apps/sim/executor/orchestrators/parallel.ts +++ b/apps/sim/executor/orchestrators/parallel.ts @@ -61,11 +61,13 @@ export class ParallelOrchestrator { let items: any[] | undefined let branchCount: number + let isEmpty = false try { - const resolved = this.resolveBranchCount(ctx, parallelConfig) + const resolved = this.resolveBranchCount(ctx, parallelConfig, parallelId) branchCount = resolved.branchCount items = resolved.items + isEmpty = resolved.isEmpty ?? false } catch (error) { const errorMessage = `Parallel Items did not resolve: ${error instanceof Error ? error.message : String(error)}` logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution }) @@ -91,6 +93,34 @@ export class ParallelOrchestrator { throw new Error(branchError) } + // Handle empty distribution - skip parallel body + if (isEmpty || branchCount === 0) { + const scope: ParallelScope = { + parallelId, + totalBranches: 0, + branchOutputs: new Map(), + completedCount: 0, + totalExpectedNodes: 0, + items: [], + isEmpty: true, + } + + if (!ctx.parallelExecutions) { + ctx.parallelExecutions = new Map() + } + ctx.parallelExecutions.set(parallelId, scope) + + // Set empty output for the parallel + this.state.setBlockOutput(parallelId, { results: [] }) + + logger.info('Parallel scope initialized with empty distribution, skipping body', { + parallelId, + branchCount: 0, + }) + + return scope + } + const { entryNodes } = this.expander.expandParallel(this.dag, parallelId, branchCount, items) const scope: ParallelScope = { @@ -127,15 +157,17 @@ export class ParallelOrchestrator { private resolveBranchCount( ctx: ExecutionContext, - config: SerializedParallel - ): { branchCount: number; items?: any[] } { + config: SerializedParallel, + parallelId: string + ): { branchCount: number; items?: any[]; isEmpty?: boolean } { if (config.parallelType === 'count') { return { branchCount: config.count ?? 1 } } const items = this.resolveDistributionItems(ctx, config) if (items.length === 0) { - return { branchCount: config.count ?? 1 } + logger.info('Parallel has empty distribution, skipping parallel body', { parallelId }) + return { branchCount: 0, items: [], isEmpty: true } } return { branchCount: items.length, items } diff --git a/apps/sim/lib/execution/isolated-vm-worker.cjs b/apps/sim/lib/execution/isolated-vm-worker.cjs index 932ef997d..3deb76166 100644 --- a/apps/sim/lib/execution/isolated-vm-worker.cjs +++ b/apps/sim/lib/execution/isolated-vm-worker.cjs @@ -8,7 +8,7 @@ const ivm = require('isolated-vm') const USER_CODE_START_LINE = 4 const pendingFetches = new Map() let fetchIdCounter = 0 -const FETCH_TIMEOUT_MS = 30000 +const FETCH_TIMEOUT_MS = 300000 // 5 minutes /** * Extract line and column from error stack or message diff --git a/apps/sim/lib/workflows/autolayout/containers.ts b/apps/sim/lib/workflows/autolayout/containers.ts index 8beeea4ce..f1f68ebf5 100644 --- a/apps/sim/lib/workflows/autolayout/containers.ts +++ b/apps/sim/lib/workflows/autolayout/containers.ts @@ -34,6 +34,7 @@ export function layoutContainers( : DEFAULT_CONTAINER_HORIZONTAL_SPACING, verticalSpacing: options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING, padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, + gridSize: options.gridSize, } for (const [parentId, childIds] of children.entries()) { @@ -56,18 +57,15 @@ export function layoutContainers( continue } - // Use the shared core layout function with container options const { nodes, dimensions } = layoutBlocksCore(childBlocks, childEdges, { isContainer: true, layoutOptions: containerOptions, }) - // Apply positions back to blocks for (const node of nodes.values()) { blocks[node.id].position = node.position } - // Update container dimensions const calculatedWidth = dimensions.width const calculatedHeight = dimensions.height diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 9187d526b..014aa37ea 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -9,6 +9,7 @@ import { getBlockMetrics, normalizePositions, prepareBlockMetrics, + snapNodesToGrid, } from '@/lib/workflows/autolayout/utils' import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { EDGE } from '@/executor/constants' @@ -84,7 +85,6 @@ export function assignLayers( ): Map { const nodes = new Map() - // Initialize nodes for (const [id, block] of Object.entries(blocks)) { nodes.set(id, { id, @@ -97,7 +97,6 @@ export function assignLayers( }) } - // Build a map of target node -> edges coming into it (to check sourceHandle later) const incomingEdgesMap = new Map() for (const edge of edges) { if (!incomingEdgesMap.has(edge.target)) { @@ -106,7 +105,6 @@ export function assignLayers( incomingEdgesMap.get(edge.target)!.push(edge) } - // Build adjacency from edges for (const edge of edges) { const sourceNode = nodes.get(edge.source) const targetNode = nodes.get(edge.target) @@ -117,7 +115,6 @@ export function assignLayers( } } - // Find starter nodes (no incoming edges) const starterNodes = Array.from(nodes.values()).filter((node) => node.incoming.size === 0) if (starterNodes.length === 0 && nodes.size > 0) { @@ -126,7 +123,6 @@ export function assignLayers( logger.warn('No starter blocks found, using first block as starter', { blockId: firstNode.id }) } - // Topological sort using Kahn's algorithm const inDegreeCount = new Map() for (const node of nodes.values()) { @@ -144,8 +140,6 @@ export function assignLayers( const node = nodes.get(nodeId)! processed.add(nodeId) - // Calculate layer based on max incoming layer + 1 - // For edges from subflow ends, add the subflow's internal depth (minus 1 to avoid double-counting) if (node.incoming.size > 0) { let maxEffectiveLayer = -1 const incomingEdges = incomingEdgesMap.get(nodeId) || [] @@ -153,16 +147,11 @@ export function assignLayers( for (const incomingId of node.incoming) { const incomingNode = nodes.get(incomingId) if (incomingNode) { - // Find edges from this incoming node to check if it's a subflow end edge const edgesFromSource = incomingEdges.filter((e) => e.source === incomingId) let additionalDepth = 0 - // Check if any edge from this source is a subflow end edge const hasSubflowEndEdge = edgesFromSource.some(isSubflowEndEdge) if (hasSubflowEndEdge && subflowDepths) { - // Get the internal depth of the subflow - // Subtract 1 because the +1 at the end of layer calculation already accounts for one layer - // E.g., if subflow has 2 internal layers (depth=2), we add 1 extra so total offset is 2 const depth = subflowDepths.get(incomingId) ?? 1 additionalDepth = Math.max(0, depth - 1) } @@ -174,7 +163,6 @@ export function assignLayers( node.layer = maxEffectiveLayer + 1 } - // Add outgoing nodes when all dependencies processed for (const targetId of node.outgoing) { const currentCount = inDegreeCount.get(targetId) || 0 inDegreeCount.set(targetId, currentCount - 1) @@ -185,7 +173,6 @@ export function assignLayers( } } - // Handle isolated nodes for (const node of nodes.values()) { if (!processed.has(node.id)) { logger.debug('Isolated node detected, assigning to layer 0', { blockId: node.id }) @@ -224,7 +211,6 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v hasOverlap = false iteration++ - // Group nodes by layer for same-layer overlap resolution const nodesByLayer = new Map() for (const node of nodes) { if (!nodesByLayer.has(node.layer)) { @@ -233,11 +219,9 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v nodesByLayer.get(node.layer)!.push(node) } - // Process each layer independently for (const [layer, layerNodes] of nodesByLayer) { if (layerNodes.length < 2) continue - // Sort by Y position for consistent processing layerNodes.sort((a, b) => a.position.y - b.position.y) for (let i = 0; i < layerNodes.length - 1; i++) { @@ -302,7 +286,6 @@ export function calculatePositions( const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b) - // Calculate max width for each layer const layerWidths = new Map() for (const layerNum of layerNumbers) { const nodesInLayer = layers.get(layerNum)! @@ -310,7 +293,6 @@ export function calculatePositions( layerWidths.set(layerNum, maxWidth) } - // Calculate cumulative X positions for each layer based on actual widths const layerXPositions = new Map() let cumulativeX = padding.x @@ -319,7 +301,6 @@ export function calculatePositions( cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing } - // Build a flat map of all nodes for quick lookups const allNodes = new Map() for (const nodesInLayer of layers.values()) { for (const node of nodesInLayer) { @@ -327,7 +308,6 @@ export function calculatePositions( } } - // Build incoming edges map for handle lookups const incomingEdgesMap = new Map() for (const edge of edges) { if (!incomingEdgesMap.has(edge.target)) { @@ -336,20 +316,16 @@ export function calculatePositions( incomingEdgesMap.get(edge.target)!.push(edge) } - // Position nodes layer by layer, aligning with connected predecessors for (const layerNum of layerNumbers) { const nodesInLayer = layers.get(layerNum)! const xPosition = layerXPositions.get(layerNum)! - // Separate containers and non-containers const containersInLayer = nodesInLayer.filter(isContainerBlock) const nonContainersInLayer = nodesInLayer.filter((n) => !isContainerBlock(n)) - // For the first layer (layer 0), position sequentially from padding.y if (layerNum === 0) { let yOffset = padding.y - // Sort containers by height for visual balance containersInLayer.sort((a, b) => b.metrics.height - a.metrics.height) for (const node of containersInLayer) { @@ -361,7 +337,6 @@ export function calculatePositions( yOffset += CONTAINER_VERTICAL_CLEARANCE } - // Sort non-containers by outgoing connections nonContainersInLayer.sort((a, b) => b.outgoing.size - a.outgoing.size) for (const node of nonContainersInLayer) { @@ -371,9 +346,7 @@ export function calculatePositions( continue } - // For subsequent layers, align with connected predecessors (handle-to-handle) for (const node of [...containersInLayer, ...nonContainersInLayer]) { - // Find the bottommost predecessor handle Y (highest value) and align to it let bestSourceHandleY = -1 let bestEdge: Edge | null = null const incomingEdges = incomingEdgesMap.get(node.id) || [] @@ -381,7 +354,6 @@ export function calculatePositions( for (const edge of incomingEdges) { const predecessor = allNodes.get(edge.source) if (predecessor) { - // Calculate actual source handle Y position based on block type and handle const sourceHandleOffset = getSourceHandleYOffset(predecessor.block, edge.sourceHandle) const sourceHandleY = predecessor.position.y + sourceHandleOffset @@ -392,20 +364,16 @@ export function calculatePositions( } } - // If no predecessors found (shouldn't happen for layer > 0), use padding if (bestSourceHandleY < 0) { bestSourceHandleY = padding.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET } - // Calculate the target handle Y offset for this node const targetHandleOffset = getTargetHandleYOffset(node.block, bestEdge?.targetHandle) - // Position node so its target handle aligns with the source handle Y node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset } } } - // Resolve vertical overlaps within layers (X overlaps prevented by cumulative positioning) resolveVerticalOverlaps(Array.from(layers.values()).flat(), verticalSpacing) } @@ -435,7 +403,7 @@ export function layoutBlocksCore( return { nodes: new Map(), dimensions: { width: 0, height: 0 } } } - const layoutOptions = + const layoutOptions: LayoutOptions = options.layoutOptions ?? (options.isContainer ? CONTAINER_LAYOUT_OPTIONS : DEFAULT_LAYOUT_OPTIONS) @@ -452,7 +420,13 @@ export function layoutBlocksCore( calculatePositions(layers, edges, layoutOptions) // 5. Normalize positions - const dimensions = normalizePositions(nodes, { isContainer: options.isContainer }) + let dimensions = normalizePositions(nodes, { isContainer: options.isContainer }) + + // 6. Snap to grid if gridSize is specified (recalculates dimensions) + const snappedDimensions = snapNodesToGrid(nodes, layoutOptions.gridSize) + if (snappedDimensions) { + dimensions = snappedDimensions + } return { nodes, dimensions } } diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts index 668366033..1346eec66 100644 --- a/apps/sim/lib/workflows/autolayout/index.ts +++ b/apps/sim/lib/workflows/autolayout/index.ts @@ -36,14 +36,13 @@ export function applyAutoLayout( const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING - // Pre-calculate container dimensions by laying out their children (bottom-up) - // This ensures accurate widths/heights before root-level layout prepareContainerDimensions( blocksCopy, edges, layoutBlocksCore, horizontalSpacing, - verticalSpacing + verticalSpacing, + options.gridSize ) const { root: rootBlockIds } = getBlocksByParent(blocksCopy) @@ -58,8 +57,6 @@ export function applyAutoLayout( (edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target) ) - // Calculate subflow depths before laying out root blocks - // This ensures blocks connected to subflow ends are positioned correctly const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers) if (Object.keys(rootBlocks).length > 0) { @@ -95,13 +92,12 @@ export function applyAutoLayout( } export type { TargetedLayoutOptions } from '@/lib/workflows/autolayout/targeted' -// Function exports export { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted' -// Type exports export type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types' export { getBlockMetrics, isContainerType, shouldSkipAutoLayout, + snapPositionToGrid, transferBlockHeights, } from '@/lib/workflows/autolayout/utils' diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index 08afa57a5..441f7681d 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { CONTAINER_PADDING, DEFAULT_HORIZONTAL_SPACING, @@ -14,12 +13,11 @@ import { isContainerType, prepareContainerDimensions, shouldSkipAutoLayout, + snapPositionToGrid, } from '@/lib/workflows/autolayout/utils' import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import type { BlockState } from '@/stores/workflows/workflow/types' -const logger = createLogger('AutoLayout:Targeted') - export interface TargetedLayoutOptions extends LayoutOptions { changedBlockIds: string[] verticalSpacing?: number @@ -39,6 +37,7 @@ export function applyTargetedLayout( changedBlockIds, verticalSpacing = DEFAULT_VERTICAL_SPACING, horizontalSpacing = DEFAULT_HORIZONTAL_SPACING, + gridSize, } = options if (!changedBlockIds || changedBlockIds.length === 0) { @@ -48,19 +47,17 @@ export function applyTargetedLayout( const changedSet = new Set(changedBlockIds) const blocksCopy: Record = JSON.parse(JSON.stringify(blocks)) - // Pre-calculate container dimensions by laying out their children (bottom-up) - // This ensures accurate widths/heights before root-level layout prepareContainerDimensions( blocksCopy, edges, layoutBlocksCore, horizontalSpacing, - verticalSpacing + verticalSpacing, + gridSize ) const groups = getBlocksByParent(blocksCopy) - // Calculate subflow depths before layout to properly position blocks after subflow ends const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers) layoutGroup( @@ -71,7 +68,8 @@ export function applyTargetedLayout( changedSet, verticalSpacing, horizontalSpacing, - subflowDepths + subflowDepths, + gridSize ) for (const [parentId, childIds] of groups.children.entries()) { @@ -83,7 +81,8 @@ export function applyTargetedLayout( changedSet, verticalSpacing, horizontalSpacing, - subflowDepths + subflowDepths, + gridSize ) } @@ -101,7 +100,8 @@ function layoutGroup( changedSet: Set, verticalSpacing: number, horizontalSpacing: number, - subflowDepths: Map + subflowDepths: Map, + gridSize?: number ): void { if (childIds.length === 0) return @@ -116,7 +116,6 @@ function layoutGroup( return } - // Determine which blocks need repositioning const requestedLayout = layoutEligibleChildIds.filter((id) => { const block = blocks[id] if (!block) return false @@ -141,7 +140,6 @@ function layoutGroup( return } - // Store old positions for anchor calculation const oldPositions = new Map() for (const id of layoutEligibleChildIds) { const block = blocks[id] @@ -149,8 +147,6 @@ function layoutGroup( oldPositions.set(id, { ...block.position }) } - // Compute layout positions using core function - // Only pass subflowDepths for root-level layout (not inside containers) const layoutPositions = computeLayoutPositions( layoutEligibleChildIds, blocks, @@ -158,7 +154,8 @@ function layoutGroup( parentBlock, horizontalSpacing, verticalSpacing, - parentId === null ? subflowDepths : undefined + parentId === null ? subflowDepths : undefined, + gridSize ) if (layoutPositions.size === 0) { @@ -168,7 +165,6 @@ function layoutGroup( return } - // Find anchor block (unchanged block with a layout position) let offsetX = 0 let offsetY = 0 @@ -185,20 +181,16 @@ function layoutGroup( } } - // Apply new positions only to blocks that need layout for (const id of needsLayout) { const block = blocks[id] const newPos = layoutPositions.get(id) if (!block || !newPos) continue - block.position = { - x: newPos.x + offsetX, - y: newPos.y + offsetY, - } + block.position = snapPositionToGrid({ x: newPos.x + offsetX, y: newPos.y + offsetY }, gridSize) } } /** - * Computes layout positions for a subset of blocks using the core layout + * Computes layout positions for a subset of blocks using the core layout function */ function computeLayoutPositions( childIds: string[], @@ -207,7 +199,8 @@ function computeLayoutPositions( parentBlock: BlockState | undefined, horizontalSpacing: number, verticalSpacing: number, - subflowDepths?: Map + subflowDepths?: Map, + gridSize?: number ): Map { const subsetBlocks: Record = {} for (const id of childIds) { @@ -228,11 +221,11 @@ function computeLayoutPositions( layoutOptions: { horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing, verticalSpacing, + gridSize, }, subflowDepths, }) - // Update parent container dimensions if applicable if (parentBlock) { parentBlock.data = { ...parentBlock.data, @@ -241,7 +234,6 @@ function computeLayoutPositions( } } - // Convert nodes to position map const positions = new Map() for (const node of nodes.values()) { positions.set(node.id, { x: node.position.x, y: node.position.y }) diff --git a/apps/sim/lib/workflows/autolayout/types.ts b/apps/sim/lib/workflows/autolayout/types.ts index 7f8cf7819..23e7ee995 100644 --- a/apps/sim/lib/workflows/autolayout/types.ts +++ b/apps/sim/lib/workflows/autolayout/types.ts @@ -7,6 +7,7 @@ export interface LayoutOptions { horizontalSpacing?: number verticalSpacing?: number padding?: { x: number; y: number } + gridSize?: number } export interface LayoutResult { diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index 8817063c9..bca3bb846 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -18,6 +18,61 @@ function resolveNumeric(value: number | undefined, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback } +/** + * Snaps a single coordinate value to the nearest grid position + */ +function snapToGrid(value: number, gridSize: number): number { + return Math.round(value / gridSize) * gridSize +} + +/** + * Snaps a position to the nearest grid point. + * Returns the original position if gridSize is 0 or not provided. + */ +export function snapPositionToGrid( + position: { x: number; y: number }, + gridSize: number | undefined +): { x: number; y: number } { + if (!gridSize || gridSize <= 0) { + return position + } + return { + x: snapToGrid(position.x, gridSize), + y: snapToGrid(position.y, gridSize), + } +} + +/** + * Snaps all node positions in a graph to grid positions and returns updated dimensions. + * Returns null if gridSize is not set or no snapping was needed. + */ +export function snapNodesToGrid( + nodes: Map, + gridSize: number | undefined +): { width: number; height: number } | null { + if (!gridSize || gridSize <= 0 || nodes.size === 0) { + return null + } + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const node of nodes.values()) { + node.position = snapPositionToGrid(node.position, gridSize) + minX = Math.min(minX, node.position.x) + minY = Math.min(minY, node.position.y) + maxX = Math.max(maxX, node.position.x + node.metrics.width) + maxY = Math.max(maxY, node.position.y + node.metrics.height) + } + + return { + width: maxX - minX + CONTAINER_PADDING * 2, + height: maxY - minY + CONTAINER_PADDING * 2, + } +} + /** * Checks if a block type is a container (loop or parallel) */ @@ -314,6 +369,7 @@ export type LayoutFunction = ( horizontalSpacing?: number verticalSpacing?: number padding?: { x: number; y: number } + gridSize?: number } subflowDepths?: Map } @@ -329,13 +385,15 @@ export type LayoutFunction = ( * @param layoutFn - The layout function to use for calculating dimensions * @param horizontalSpacing - Horizontal spacing between blocks * @param verticalSpacing - Vertical spacing between blocks + * @param gridSize - Optional grid size for snap-to-grid */ export function prepareContainerDimensions( blocks: Record, edges: Edge[], layoutFn: LayoutFunction, horizontalSpacing: number, - verticalSpacing: number + verticalSpacing: number, + gridSize?: number ): void { const { children } = getBlocksByParent(blocks) @@ -402,6 +460,7 @@ export function prepareContainerDimensions( layoutOptions: { horizontalSpacing: horizontalSpacing * 0.85, verticalSpacing, + gridSize, }, }) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index f912e92be..195103ffe 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -102,7 +102,7 @@ export const azureOpenAIProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens if (request.reasoningEffort !== undefined) payload.reasoning_effort = request.reasoningEffort if (request.verbosity !== undefined) payload.verbosity = request.verbosity diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 3953c6715..c18560048 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -77,7 +77,7 @@ export const cerebrasProvider: ProviderConfig = { messages: allMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens if (request.responseFormat) { payload.response_format = { type: 'json_schema', diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 2aa92b04f..026342498 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -81,7 +81,7 @@ export const deepseekProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_tokens = request.maxTokens let preparedTools: ReturnType | null = null diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index a7aca1aaa..2dca22e5b 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -349,7 +349,7 @@ export async function executeGeminiRequest( if (request.temperature !== undefined) { geminiConfig.temperature = request.temperature } - if (request.maxTokens !== undefined) { + if (request.maxTokens != null) { geminiConfig.maxOutputTokens = request.maxTokens } if (systemInstruction) { diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 724094784..c5040aab4 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -123,17 +123,21 @@ export function extractFunctionCallPart(candidate: Candidate | undefined): Part } /** - * Converts usage metadata from SDK response to our format + * Converts usage metadata from SDK response to our format. + * Per Gemini docs, total = promptTokenCount + candidatesTokenCount + toolUsePromptTokenCount + thoughtsTokenCount + * We include toolUsePromptTokenCount in input and thoughtsTokenCount in output for correct billing. */ export function convertUsageMetadata( usageMetadata: GenerateContentResponseUsageMetadata | undefined ): GeminiUsage { - const promptTokenCount = usageMetadata?.promptTokenCount ?? 0 - const candidatesTokenCount = usageMetadata?.candidatesTokenCount ?? 0 + const thoughtsTokenCount = usageMetadata?.thoughtsTokenCount ?? 0 + const toolUsePromptTokenCount = usageMetadata?.toolUsePromptTokenCount ?? 0 + const promptTokenCount = (usageMetadata?.promptTokenCount ?? 0) + toolUsePromptTokenCount + const candidatesTokenCount = (usageMetadata?.candidatesTokenCount ?? 0) + thoughtsTokenCount return { promptTokenCount, candidatesTokenCount, - totalTokenCount: usageMetadata?.totalTokenCount ?? promptTokenCount + candidatesTokenCount, + totalTokenCount: usageMetadata?.totalTokenCount ?? 0, } } diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index c5dad01ef..7be9b7386 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -74,7 +74,7 @@ export const groqProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens if (request.responseFormat) { payload.response_format = { diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 736b11c24..f99a3e210 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -91,7 +91,7 @@ export const mistralProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_tokens = request.maxTokens if (request.responseFormat) { payload.response_format = { diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 06ee14ae6..5922bf8e7 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -1130,7 +1130,7 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'cerebras', name: 'Cerebras', description: 'Cerebras Cloud LLMs', - defaultModel: 'cerebras/llama-3.3-70b', + defaultModel: 'cerebras/gpt-oss-120b', modelPatterns: [/^cerebras/], icon: CerebrasIcon, capabilities: { @@ -1138,44 +1138,64 @@ export const PROVIDER_DEFINITIONS: Record = { }, models: [ { - id: 'cerebras/llama-3.1-8b', + id: 'cerebras/gpt-oss-120b', + pricing: { + input: 0.35, + output: 0.75, + updatedAt: '2026-01-27', + }, + capabilities: {}, + contextWindow: 131000, + }, + { + id: 'cerebras/llama3.1-8b', pricing: { input: 0.1, output: 0.1, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 32000, }, - { - id: 'cerebras/llama-3.1-70b', - pricing: { - input: 0.6, - output: 0.6, - updatedAt: '2025-10-11', - }, - capabilities: {}, - contextWindow: 128000, - }, { id: 'cerebras/llama-3.3-70b', pricing: { - input: 0.6, - output: 0.6, - updatedAt: '2025-10-11', + input: 0.85, + output: 1.2, + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 128000, }, { - id: 'cerebras/llama-4-scout-17b-16e-instruct', + id: 'cerebras/qwen-3-32b', pricing: { - input: 0.11, - output: 0.34, - updatedAt: '2025-10-11', + input: 0.4, + output: 0.8, + updatedAt: '2026-01-27', }, capabilities: {}, - contextWindow: 10000000, + contextWindow: 131000, + }, + { + id: 'cerebras/qwen-3-235b-a22b-instruct-2507', + pricing: { + input: 0.6, + output: 1.2, + updatedAt: '2026-01-27', + }, + capabilities: {}, + contextWindow: 131000, + }, + { + id: 'cerebras/zai-glm-4.7', + pricing: { + input: 2.25, + output: 2.75, + updatedAt: '2026-01-27', + }, + capabilities: {}, + contextWindow: 131000, }, ], }, @@ -1194,8 +1214,8 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'groq/openai/gpt-oss-120b', pricing: { input: 0.15, - output: 0.75, - updatedAt: '2025-10-11', + output: 0.6, + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 131072, @@ -1203,9 +1223,29 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'groq/openai/gpt-oss-20b', pricing: { - input: 0.01, - output: 0.25, - updatedAt: '2025-10-11', + input: 0.075, + output: 0.3, + updatedAt: '2026-01-27', + }, + capabilities: {}, + contextWindow: 131072, + }, + { + id: 'groq/openai/gpt-oss-safeguard-20b', + pricing: { + input: 0.075, + output: 0.3, + updatedAt: '2026-01-27', + }, + capabilities: {}, + contextWindow: 131072, + }, + { + id: 'groq/qwen/qwen3-32b', + pricing: { + input: 0.29, + output: 0.59, + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 131072, @@ -1215,7 +1255,7 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 0.05, output: 0.08, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 131072, @@ -1225,27 +1265,17 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 0.59, output: 0.79, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 131072, }, { - id: 'groq/llama-4-scout-17b-instruct', + id: 'groq/meta-llama/llama-4-scout-17b-16e-instruct', pricing: { input: 0.11, output: 0.34, - updatedAt: '2025-10-11', - }, - capabilities: {}, - contextWindow: 131072, - }, - { - id: 'groq/llama-4-maverick-17b-instruct', - pricing: { - input: 0.5, - output: 0.77, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 131072, @@ -1253,9 +1283,9 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'groq/meta-llama/llama-4-maverick-17b-128e-instruct', pricing: { - input: 0.5, - output: 0.77, - updatedAt: '2025-10-11', + input: 0.2, + output: 0.6, + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 131072, @@ -1265,7 +1295,7 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 0.04, output: 0.04, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 8192, @@ -1275,27 +1305,37 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 0.59, output: 0.79, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 128000, }, { - id: 'groq/moonshotai/kimi-k2-instruct', + id: 'groq/deepseek-r1-distill-qwen-32b', + pricing: { + input: 0.69, + output: 0.69, + updatedAt: '2026-01-27', + }, + capabilities: {}, + contextWindow: 128000, + }, + { + id: 'groq/moonshotai/kimi-k2-instruct-0905', pricing: { input: 1.0, output: 3.0, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, - contextWindow: 131072, + contextWindow: 262144, }, { id: 'groq/meta-llama/llama-guard-4-12b', pricing: { input: 0.2, output: 0.2, - updatedAt: '2025-10-11', + updatedAt: '2026-01-27', }, capabilities: {}, contextWindow: 131072, diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 7b73d1f18..921c1afd0 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -105,7 +105,7 @@ export const ollamaProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_tokens = request.maxTokens if (request.responseFormat) { payload.response_format = { diff --git a/apps/sim/providers/openai/index.ts b/apps/sim/providers/openai/index.ts index 0d7342fc9..b2cecfceb 100644 --- a/apps/sim/providers/openai/index.ts +++ b/apps/sim/providers/openai/index.ts @@ -81,7 +81,7 @@ export const openaiProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens if (request.reasoningEffort !== undefined) payload.reasoning_effort = request.reasoningEffort if (request.verbosity !== undefined) payload.verbosity = request.verbosity diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index d937e3d0e..57246c437 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -121,7 +121,7 @@ export const openRouterProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_tokens = request.maxTokens let preparedTools: ReturnType | null = null let hasActiveTools = false @@ -516,7 +516,7 @@ export const openRouterProvider: ProviderConfig = { return streamingResult as StreamingExecution } - if (request.responseFormat && hasActiveTools && toolCalls.length > 0) { + if (request.responseFormat && hasActiveTools) { const finalPayload: any = { model: payload.model, messages: [...currentMessages], diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 4af4ae9d7..0df587264 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -135,7 +135,7 @@ export const vllmProvider: ProviderConfig = { } if (request.temperature !== undefined) payload.temperature = request.temperature - if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens if (request.responseFormat) { payload.response_format = { diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 72602ec50..8138265a3 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -92,7 +92,7 @@ export const xAIProvider: ProviderConfig = { } if (request.temperature !== undefined) basePayload.temperature = request.temperature - if (request.maxTokens !== undefined) basePayload.max_tokens = request.maxTokens + if (request.maxTokens != null) basePayload.max_completion_tokens = request.maxTokens let preparedTools: ReturnType | null = null if (tools?.length) { diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index c41e20f7a..ac591e7b3 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -1,15 +1,155 @@ +import { RepeatIcon, SplitIcon } from 'lucide-react' import { create } from 'zustand' -import type { SearchModalState } from './types' +import { devtools } from 'zustand/middleware' +import { getToolOperationsIndex } from '@/lib/search/tool-operations' +import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' +import { getAllBlocks } from '@/blocks' +import type { + SearchBlockItem, + SearchData, + SearchDocItem, + SearchModalState, + SearchToolOperationItem, +} from './types' -export const useSearchModalStore = create((set) => ({ - isOpen: false, - setOpen: (open: boolean) => { - set({ isOpen: open }) - }, - open: () => { - set({ isOpen: true }) - }, - close: () => { - set({ isOpen: false }) - }, -})) +const initialData: SearchData = { + blocks: [], + tools: [], + triggers: [], + toolOperations: [], + docs: [], + isInitialized: false, +} + +export const useSearchModalStore = create()( + devtools( + (set, get) => ({ + isOpen: false, + data: initialData, + + setOpen: (open: boolean) => { + set({ isOpen: open }) + }, + + open: () => { + set({ isOpen: true }) + }, + + close: () => { + set({ isOpen: false }) + }, + + initializeData: (filterBlocks) => { + const allBlocks = getAllBlocks() + const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks + + const regularBlocks: SearchBlockItem[] = [] + const tools: SearchBlockItem[] = [] + const docs: SearchDocItem[] = [] + + for (const block of filteredAllBlocks) { + if (block.hideFromToolbar) continue + + const searchItem: SearchBlockItem = { + id: block.type, + name: block.name, + description: block.description || '', + icon: block.icon, + bgColor: block.bgColor || '#6B7280', + type: block.type, + } + + if (block.category === 'blocks' && block.type !== 'starter') { + regularBlocks.push(searchItem) + } else if (block.category === 'tools') { + tools.push(searchItem) + } + + if (block.docsLink) { + docs.push({ + id: `docs-${block.type}`, + name: block.name, + icon: block.icon, + href: block.docsLink, + }) + } + } + + const specialBlocks: SearchBlockItem[] = [ + { + id: 'loop', + name: 'Loop', + description: 'Create a Loop', + icon: RepeatIcon, + bgColor: '#2FB3FF', + type: 'loop', + }, + { + id: 'parallel', + name: 'Parallel', + description: 'Parallel Execution', + icon: SplitIcon, + bgColor: '#FEE12B', + type: 'parallel', + }, + ] + + const blocks = [...regularBlocks, ...(filterBlocks(specialBlocks) as SearchBlockItem[])] + + const allTriggers = getTriggersForSidebar() + const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers + const priorityOrder = ['Start', 'Schedule', 'Webhook'] + + const sortedTriggers = [...filteredTriggers].sort((a, b) => { + const aIndex = priorityOrder.indexOf(a.name) + const bIndex = priorityOrder.indexOf(b.name) + const aHasPriority = aIndex !== -1 + const bHasPriority = bIndex !== -1 + + if (aHasPriority && bHasPriority) return aIndex - bIndex + if (aHasPriority) return -1 + if (bHasPriority) return 1 + return a.name.localeCompare(b.name) + }) + + const triggers = sortedTriggers.map( + (block): SearchBlockItem => ({ + id: block.type, + name: block.name, + description: block.description || '', + icon: block.icon, + bgColor: block.bgColor || '#6B7280', + type: block.type, + config: block, + }) + ) + + const allowedBlockTypes = new Set(tools.map((t) => t.type)) + const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex() + .filter((op) => allowedBlockTypes.has(op.blockType)) + .map((op) => ({ + id: op.id, + name: op.operationName, + searchValue: `${op.serviceName} ${op.operationName}`, + icon: op.icon, + bgColor: op.bgColor, + blockType: op.blockType, + operationId: op.operationId, + keywords: op.aliases, + })) + + set({ + data: { + blocks, + tools, + triggers, + toolOperations, + docs, + isInitialized: true, + }, + }) + }, + }), + { name: 'search-modal-store' } + ) +) diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index c3170e951..07dde9d09 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -1,3 +1,55 @@ +import type { ComponentType } from 'react' +import type { BlockConfig } from '@/blocks/types' + +/** + * Represents a block item in the search results. + */ +export interface SearchBlockItem { + id: string + name: string + description: string + icon: ComponentType<{ className?: string }> + bgColor: string + type: string + config?: BlockConfig +} + +/** + * Represents a tool operation item in the search results. + */ +export interface SearchToolOperationItem { + id: string + name: string + searchValue: string + icon: ComponentType<{ className?: string }> + bgColor: string + blockType: string + operationId: string + keywords: string[] +} + +/** + * Represents a doc item in the search results. + */ +export interface SearchDocItem { + id: string + name: string + icon: ComponentType<{ className?: string }> + href: string +} + +/** + * Pre-computed search data that is initialized on app load. + */ +export interface SearchData { + blocks: SearchBlockItem[] + tools: SearchBlockItem[] + triggers: SearchBlockItem[] + toolOperations: SearchToolOperationItem[] + docs: SearchDocItem[] + isInitialized: boolean +} + /** * Global state for the universal search modal. * @@ -8,18 +60,27 @@ export interface SearchModalState { /** Whether the search modal is currently open. */ isOpen: boolean + + /** Pre-computed search data. */ + data: SearchData + /** * Explicitly set the open state of the modal. - * - * @param open - New open state. */ setOpen: (open: boolean) => void + /** * Convenience method to open the modal. */ open: () => void + /** * Convenience method to close the modal. */ close: () => void + + /** + * Initialize search data. Called once on app load. + */ + initializeData: (filterBlocks: (blocks: T[]) => T[]) => void } diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 37556793b..9a20977ae 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -253,23 +253,6 @@ describe('executeTool Function', () => { vi.restoreAllMocks() }) - it('should handle errors from tools', async () => { - setupFetchMock({ status: 400, ok: false, json: { error: 'Bad request' } }) - - const result = await executeTool( - 'http_request', - { - url: 'https://api.example.com/data', - method: 'GET', - }, - true - ) - - expect(result.success).toBe(false) - expect(result.error).toBeDefined() - expect(result.timing).toBeDefined() - }) - it('should add timing information to results', async () => { const result = await executeTool( 'http_request', diff --git a/helm/sim/examples/values-azure.yaml b/helm/sim/examples/values-azure.yaml index 982605fa7..a11b55adc 100644 --- a/helm/sim/examples/values-azure.yaml +++ b/helm/sim/examples/values-azure.yaml @@ -4,8 +4,9 @@ # Global configuration global: imageRegistry: "ghcr.io" - # Use "managed-csi-premium" for Premium SSD (requires Premium storage-capable VMs like Standard_DS*) - # Use "managed-csi" for Standard SSD (works with all VM types) + # Use "managed-csi-premium" for Premium SSD, "managed-csi" for Standard SSD + # IMPORTANT: For production, use a StorageClass with reclaimPolicy: Retain + # to protect database volumes from accidental deletion. storageClass: "managed-csi" # Main application diff --git a/helm/sim/examples/values-production.yaml b/helm/sim/examples/values-production.yaml index 794afa4ac..9874cb1a5 100644 --- a/helm/sim/examples/values-production.yaml +++ b/helm/sim/examples/values-production.yaml @@ -4,6 +4,7 @@ # Global configuration global: imageRegistry: "ghcr.io" + # For production, use a StorageClass with reclaimPolicy: Retain storageClass: "managed-csi-premium" # Main application diff --git a/helm/sim/templates/cert-manager-issuers.yaml b/helm/sim/templates/cert-manager-issuers.yaml new file mode 100644 index 000000000..aef2a61a0 --- /dev/null +++ b/helm/sim/templates/cert-manager-issuers.yaml @@ -0,0 +1,84 @@ +{{- if .Values.certManager.enabled }} +{{- /* + cert-manager Issuer Bootstrap Pattern + + PREREQUISITE: cert-manager must be installed in your cluster before enabling this. + The root CA Certificate is created in the namespace specified by certManager.rootCA.namespace + (defaults to "cert-manager"). Ensure this namespace exists and cert-manager is running there. + + Install cert-manager: https://cert-manager.io/docs/installation/ + + This implements the recommended pattern from cert-manager documentation: + 1. A self-signed ClusterIssuer (for bootstrapping the root CA only) + 2. A root CA Certificate (self-signed, used to sign other certificates) + 3. A CA ClusterIssuer (uses the root CA to sign certificates) + + Reference: https://cert-manager.io/docs/configuration/selfsigned/ +*/ -}} + +--- +# 1. Self-Signed ClusterIssuer (Bootstrap Only) +# This issuer is used ONLY to create the root CA certificate. +# It should NOT be used directly for application certificates. +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: {{ .Values.certManager.selfSignedIssuer.name }} + labels: + {{- include "sim.labels" . | nindent 4 }} + app.kubernetes.io/component: cert-manager +spec: + selfSigned: {} + +--- +# 2. Root CA Certificate +# This certificate is signed by the self-signed issuer and becomes the root of trust. +# The secret created here will be used by the CA issuer to sign certificates. +# NOTE: This must be created in the cert-manager namespace (or the namespace specified +# in certManager.rootCA.namespace). Ensure cert-manager is installed there first. +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ .Values.certManager.rootCA.certificateName }} + namespace: {{ .Values.certManager.rootCA.namespace | default "cert-manager" }} # Must match cert-manager's cluster-resource-namespace + labels: + {{- include "sim.labels" . | nindent 4 }} + app.kubernetes.io/component: cert-manager +spec: + isCA: true + commonName: {{ .Values.certManager.rootCA.commonName }} + secretName: {{ .Values.certManager.rootCA.secretName }} + duration: {{ .Values.certManager.rootCA.duration | default "87600h" }} + renewBefore: {{ .Values.certManager.rootCA.renewBefore | default "2160h" }} + privateKey: + algorithm: {{ .Values.certManager.rootCA.privateKey.algorithm | default "RSA" }} + size: {{ .Values.certManager.rootCA.privateKey.size | default 4096 }} + subject: + organizations: + {{- if .Values.certManager.rootCA.subject.organizations }} + {{- toYaml .Values.certManager.rootCA.subject.organizations | nindent 6 }} + {{- else }} + - {{ .Release.Name }} + {{- end }} + issuerRef: + name: {{ .Values.certManager.selfSignedIssuer.name }} + kind: ClusterIssuer + group: cert-manager.io + +--- +# 3. CA ClusterIssuer +# This is the issuer that should be used by applications to obtain certificates. +# It signs certificates using the root CA created above. +# NOTE: This issuer may briefly show "not ready" on first install while cert-manager +# processes the Certificate above and creates the secret. It will auto-reconcile. +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: {{ .Values.certManager.caIssuer.name }} + labels: + {{- include "sim.labels" . | nindent 4 }} + app.kubernetes.io/component: cert-manager +spec: + ca: + secretName: {{ .Values.certManager.rootCA.secretName }} +{{- end }} diff --git a/helm/sim/templates/certificate-postgresql.yaml b/helm/sim/templates/certificate-postgresql.yaml index bbe390adf..84f507caf 100644 --- a/helm/sim/templates/certificate-postgresql.yaml +++ b/helm/sim/templates/certificate-postgresql.yaml @@ -11,12 +11,12 @@ spec: duration: {{ .Values.postgresql.tls.duration | default "87600h" }} # Default: 10 years renewBefore: {{ .Values.postgresql.tls.renewBefore | default "2160h" }} # Default: 90 days before expiry isCA: false - {{- if .Values.postgresql.tls.rotationPolicy }} - rotationPolicy: {{ .Values.postgresql.tls.rotationPolicy }} - {{- end }} privateKey: algorithm: {{ .Values.postgresql.tls.privateKey.algorithm | default "RSA" }} size: {{ .Values.postgresql.tls.privateKey.size | default 4096 }} + {{- if .Values.postgresql.tls.rotationPolicy }} + rotationPolicy: {{ .Values.postgresql.tls.rotationPolicy }} + {{- end }} usages: - server auth - client auth diff --git a/helm/sim/templates/configmap-branding.yaml b/helm/sim/templates/configmap-branding.yaml index 4e22d3a2b..ae05c4dd8 100644 --- a/helm/sim/templates/configmap-branding.yaml +++ b/helm/sim/templates/configmap-branding.yaml @@ -1,4 +1,4 @@ -{{- if .Values.branding.enabled }} +{{- if and .Values.branding.enabled (or .Values.branding.files .Values.branding.binaryFiles) }} --- # Branding ConfigMap # Mounts custom branding assets (logos, CSS, etc.) into the application diff --git a/helm/sim/templates/deployment-app.yaml b/helm/sim/templates/deployment-app.yaml index 5362dd43e..31be48aa3 100644 --- a/helm/sim/templates/deployment-app.yaml +++ b/helm/sim/templates/deployment-app.yaml @@ -110,9 +110,10 @@ spec: {{- end }} {{- include "sim.resources" .Values.app | nindent 10 }} {{- include "sim.securityContext" .Values.app | nindent 10 }} - {{- if or .Values.branding.enabled .Values.extraVolumeMounts .Values.app.extraVolumeMounts }} + {{- $hasBranding := and .Values.branding.enabled (or .Values.branding.files .Values.branding.binaryFiles) }} + {{- if or $hasBranding .Values.extraVolumeMounts .Values.app.extraVolumeMounts }} volumeMounts: - {{- if .Values.branding.enabled }} + {{- if $hasBranding }} - name: branding mountPath: {{ .Values.branding.mountPath | default "/app/public/branding" }} readOnly: true @@ -124,9 +125,10 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} {{- end }} - {{- if or .Values.branding.enabled .Values.extraVolumes .Values.app.extraVolumes }} + {{- $hasBranding := and .Values.branding.enabled (or .Values.branding.files .Values.branding.binaryFiles) }} + {{- if or $hasBranding .Values.extraVolumes .Values.app.extraVolumes }} volumes: - {{- if .Values.branding.enabled }} + {{- if $hasBranding }} - name: branding configMap: name: {{ include "sim.fullname" . }}-branding diff --git a/helm/sim/templates/gpu-device-plugin.yaml b/helm/sim/templates/gpu-device-plugin.yaml index df9a30b3d..b7bb9a628 100644 --- a/helm/sim/templates/gpu-device-plugin.yaml +++ b/helm/sim/templates/gpu-device-plugin.yaml @@ -1,6 +1,36 @@ {{- if and .Values.ollama.enabled .Values.ollama.gpu.enabled }} --- -# NVIDIA Device Plugin DaemonSet for GPU support +# 1. ConfigMap for NVIDIA Device Plugin Configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "sim.fullname" . }}-nvidia-device-plugin-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "sim.labels" . | nindent 4 }} + app.kubernetes.io/component: nvidia-device-plugin +data: + config.yaml: | + version: v1 + flags: + {{- if eq .Values.ollama.gpu.strategy "mig" }} + migStrategy: "single" + {{- else }} + migStrategy: "none" + {{- end }} + failOnInitError: false + plugin: + passDeviceSpecs: true + deviceListStrategy: envvar + {{- if eq .Values.ollama.gpu.strategy "time-slicing" }} + sharing: + timeSlicing: + resources: + - name: nvidia.com/gpu + replicas: {{ .Values.ollama.gpu.timeSlicingReplicas | default 5 }} + {{- end }} +--- +# 2. NVIDIA Device Plugin DaemonSet for GPU support apiVersion: apps/v1 kind: DaemonSet metadata: @@ -35,9 +65,6 @@ spec: # Only schedule on nodes with NVIDIA GPUs accelerator: nvidia priorityClassName: system-node-critical - runtimeClassName: nvidia - hostNetwork: true - hostPID: true volumes: - name: device-plugin hostPath: @@ -48,22 +75,21 @@ spec: - name: sys hostPath: path: /sys - - name: proc-driver-nvidia - hostPath: - path: /proc/driver/nvidia + # Volume to mount the ConfigMap + - name: nvidia-device-plugin-config + configMap: + name: {{ include "sim.fullname" . }}-nvidia-device-plugin-config containers: - name: nvidia-device-plugin - image: nvcr.io/nvidia/k8s-device-plugin:v0.14.5 + image: nvcr.io/nvidia/k8s-device-plugin:v0.18.2 imagePullPolicy: Always args: - - --mig-strategy=single - - --pass-device-specs=true - - --fail-on-init-error=false - - --device-list-strategy=envvar - - --nvidia-driver-root=/host-sys/fs/cgroup + - "--config-file=/etc/device-plugin/config.yaml" + {{- if eq .Values.ollama.gpu.strategy "mig" }} env: - name: NVIDIA_MIG_MONITOR_DEVICES value: all + {{- end }} securityContext: allowPrivilegeEscalation: false capabilities: @@ -74,29 +100,16 @@ spec: - name: dev mountPath: /dev - name: sys - mountPath: /host-sys + mountPath: /sys readOnly: true - - name: proc-driver-nvidia - mountPath: /proc/driver/nvidia + - name: nvidia-device-plugin-config + mountPath: /etc/device-plugin/ readOnly: true resources: requests: cpu: 50m - memory: 10Mi + memory: 20Mi limits: cpu: 50m - memory: 20Mi - {{- if .Values.nodeSelector }} - nodeSelector: - {{- toYaml .Values.nodeSelector | nindent 8 }} - {{- end }} ---- -# RuntimeClass for NVIDIA Container Runtime -apiVersion: node.k8s.io/v1 -kind: RuntimeClass -metadata: - name: {{ include "sim.fullname" . }}-nvidia - labels: - {{- include "sim.labels" . | nindent 4 }} -handler: nvidia -{{- end }} \ No newline at end of file + memory: 50Mi +{{- end }} diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index dc09a9ce2..e78e0f917 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -400,8 +400,10 @@ postgresql: algorithm: RSA # RSA or ECDSA size: 4096 # Key size in bits # Issuer reference (REQUIRED if tls.enabled is true) + # By default, references the CA issuer created by certManager.caIssuer + # Make sure certManager.enabled is true, or provide your own issuer issuerRef: - name: selfsigned-cluster-issuer # Name of your cert-manager Issuer/ClusterIssuer + name: sim-ca-issuer # Name of your cert-manager Issuer/ClusterIssuer kind: ClusterIssuer # ClusterIssuer or Issuer group: "" # Optional: cert-manager.io (leave empty for default) # Additional DNS names (optional) @@ -463,20 +465,26 @@ externalDatabase: ollama: # Enable/disable Ollama deployment enabled: false - + # Image configuration image: repository: ollama/ollama tag: latest pullPolicy: Always - + # Number of replicas replicaCount: 1 - + # GPU configuration gpu: enabled: false count: 1 + # GPU sharing strategy: "mig" (Multi-Instance GPU) or "time-slicing" + # - mig: Hardware-level GPU partitioning (requires supported GPUs like A100) + # - time-slicing: Software-level GPU sharing (works with most NVIDIA GPUs) + strategy: "time-slicing" + # Number of time-slicing replicas (only used when strategy is "time-slicing") + timeSlicingReplicas: 5 # Node selector for GPU workloads (adjust labels based on your cluster configuration) nodeSelector: @@ -1185,4 +1193,53 @@ externalSecrets: # External database password (when using managed database services) externalDatabase: # Path to external database password in external store - password: "" \ No newline at end of file + password: "" + +# cert-manager configuration +# Prerequisites: Install cert-manager in your cluster first +# See: https://cert-manager.io/docs/installation/ +# +# This implements the recommended CA bootstrap pattern from cert-manager: +# 1. Self-signed ClusterIssuer (bootstrap only - creates root CA) +# 2. Root CA Certificate (self-signed, becomes the trust anchor) +# 3. CA ClusterIssuer (signs application certificates using root CA) +# +# Reference: https://cert-manager.io/docs/configuration/selfsigned/ +certManager: + # Enable/disable cert-manager issuer resources + enabled: false + + # Self-signed ClusterIssuer (used ONLY to bootstrap the root CA) + # Do not reference this issuer directly for application certificates + selfSignedIssuer: + name: "sim-selfsigned-bootstrap-issuer" + + # Root CA Certificate configuration + # This certificate is signed by the self-signed issuer and used as the trust anchor + rootCA: + # Name of the Certificate resource + certificateName: "sim-root-ca" + # Namespace where the root CA certificate and secret will be created + # Must match cert-manager's cluster-resource-namespace (default: cert-manager) + namespace: "cert-manager" + # Common name for the root CA certificate + commonName: "sim-root-ca" + # Secret name where the root CA certificate and key will be stored + secretName: "sim-root-ca-secret" + # Certificate validity duration (default: 10 years) + duration: "87600h" + # Renew before expiry (default: 90 days) + renewBefore: "2160h" + # Private key configuration + privateKey: + algorithm: RSA + size: 4096 + # Subject configuration + subject: + organizations: [] + # If empty, defaults to the release name + + # CA ClusterIssuer configuration + # This is the issuer that applications should reference for obtaining certificates + caIssuer: + name: "sim-ca-issuer" \ No newline at end of file