Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 97 additions & 12 deletions apps/docs/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,27 +86,112 @@ export async function GET(request: NextRequest) {
)
.limit(candidateLimit)

const seenIds = new Set<string>()
const mergedResults = []
const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']

for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
mergedResults.push(vectorResults[i])
seenIds.add(vectorResults[i].chunkId)
const vectorRankMap = new Map<string, number>()
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))

const keywordRankMap = new Map<string, number>()
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))

const allChunkIds = new Set([
...vectorResults.map((r) => r.chunkId),
...keywordResults.map((r) => r.chunkId),
])

const k = 60
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
const scoredResults: ResultWithRRF[] = []

for (const chunkId of allChunkIds) {
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY

const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)

const result =
vectorResults.find((r) => r.chunkId === chunkId) ||
keywordResults.find((r) => r.chunkId === chunkId)

if (result) {
scoredResults.push({ ...result, rrfScore })
}
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
mergedResults.push(keywordResults[i])
seenIds.add(keywordResults[i].chunkId)
}

scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)

const localeFilteredResults = scoredResults.filter((result) => {
const firstPart = result.sourceDocument.split('/')[0]
if (knownLocales.includes(firstPart)) {
return firstPart === locale
}
return locale === 'en'
})

const queryLower = query.toLowerCase()
const getTitleBoost = (result: ResultWithRRF): number => {
const fileName = result.sourceDocument
.replace('.mdx', '')
.split('/')
.pop()
?.toLowerCase()
?.replace(/_/g, ' ')

if (fileName === queryLower) return 0.01
if (fileName?.includes(queryLower)) return 0.005
return 0
}

localeFilteredResults.sort((a, b) => {
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
})

const pageMap = new Map<string, ResultWithRRF>()

for (const result of localeFilteredResults) {
const pageKey = result.sourceDocument
const existing = pageMap.get(pageKey)

if (!existing || result.rrfScore > existing.rrfScore) {
pageMap.set(pageKey, result)
}
}

const filteredResults = mergedResults.slice(0, limit)
const searchResults = filteredResults.map((result) => {
const deduplicatedResults = Array.from(pageMap.values())
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
.slice(0, limit)

const searchResults = deduplicatedResults.map((result) => {
const title = result.headerText || result.sourceDocument.replace('.mdx', '')

const pathParts = result.sourceDocument
.replace('.mdx', '')
.split('/')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.filter((part) => part !== 'index' && !knownLocales.includes(part))
.map((part) => {
return part
.replace(/_/g, ' ')
.split(' ')
.map((word) => {
const acronyms = [
'api',
'mcp',
'sdk',
'url',
'http',
'json',
'xml',
'html',
'css',
'ai',
]
if (acronyms.includes(word.toLowerCase())) {
return word.toUpperCase()
}
return word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
})

return {
id: result.chunkId,
Expand Down
16 changes: 13 additions & 3 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,8 @@ const WorkflowContent = React.memo(() => {
parentId?: string,
extent?: 'parent',
autoConnectEdge?: Edge,
triggerMode?: boolean
triggerMode?: boolean,
presetSubBlockValues?: Record<string, unknown>
) => {
setPendingSelection([id])
setSelectedEdges(new Map())
Expand Down Expand Up @@ -722,6 +723,14 @@ const WorkflowContent = React.memo(() => {
}
}

// Apply preset subblock values (e.g., from tool-operation search)
if (presetSubBlockValues) {
if (!subBlockValues[id]) {
subBlockValues[id] = {}
}
Object.assign(subBlockValues[id], presetSubBlockValues)
}

collaborativeBatchAddBlocks(
[block],
autoConnectEdge ? [autoConnectEdge] : [],
Expand Down Expand Up @@ -1489,7 +1498,7 @@ const WorkflowContent = React.memo(() => {
return
}

const { type, enableTriggerMode } = event.detail
const { type, enableTriggerMode, presetOperation } = event.detail

if (!type) return
if (type === 'connectionBlock') return
Expand Down Expand Up @@ -1552,7 +1561,8 @@ const WorkflowContent = React.memo(() => {
undefined,
undefined,
autoConnectEdge,
enableTriggerMode
enableTriggerMode,
presetOperation ? { operation: presetOperation } : undefined
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation'
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
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 { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
Expand Down Expand Up @@ -81,10 +82,12 @@ type SearchItem = {
color?: string
href?: string
shortcut?: string
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
isCurrent?: boolean
blockType?: string
config?: any
operationId?: string
aliases?: string[]
}

interface SearchResultItemProps {
Expand All @@ -101,7 +104,11 @@ const SearchResultItem = memo(function SearchResultItem({
onItemClick,
}: SearchResultItemProps) {
const Icon = item.icon
const showColoredIcon = item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
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'

Expand Down Expand Up @@ -278,6 +285,24 @@ export const SearchModal = memo(function SearchModal({
)
}, [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 pages = useMemo(
(): PageItem[] => [
{
Expand Down Expand Up @@ -396,6 +421,19 @@ export const SearchModal = memo(function SearchModal({
})
})

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,
Expand All @@ -407,10 +445,10 @@ export const SearchModal = memo(function SearchModal({
})

return items
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])

const sectionOrder = useMemo<SearchItem['type'][]>(
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
[]
)

Expand Down Expand Up @@ -457,6 +495,7 @@ export const SearchModal = memo(function SearchModal({
page: [],
trigger: [],
block: [],
'tool-operation': [],
tool: [],
doc: [],
}
Expand Down Expand Up @@ -512,6 +551,17 @@ export const SearchModal = memo(function SearchModal({
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
Expand Down Expand Up @@ -592,6 +642,7 @@ export const SearchModal = memo(function SearchModal({
page: 'Pages',
trigger: 'Triggers',
block: 'Blocks',
'tool-operation': 'Tool Operations',
tool: 'Tools',
doc: 'Docs',
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ export interface SearchableItem {
name: string
description?: string
type: string
aliases?: string[]
[key: string]: any
}

export interface SearchResult<T extends SearchableItem> {
item: T
score: number
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description'
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
Expand Down Expand Up @@ -67,6 +69,39 @@ function calculateFieldScore(
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 }
}

/**
* Search items using tiered matching algorithm
* Returns items sorted by relevance (highest score first)
Expand All @@ -90,15 +125,20 @@ export function searchItems<T extends SearchableItem>(
? calculateFieldScore(normalizedQuery, item.description)
: { score: 0, matchType: null }

const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)

const nameScore = nameMatch.score
const descScore = descMatch.score * DESCRIPTION_WEIGHT
const aliasScore = aliasMatch.score

const bestScore = Math.max(nameScore, descScore)
const bestScore = Math.max(nameScore, descScore, aliasScore)

if (bestScore > 0) {
let matchType: SearchResult<T>['matchType'] = 'substring'
if (nameScore >= descScore) {
if (nameScore >= descScore && nameScore >= aliasScore) {
matchType = nameMatch.matchType || 'substring'
} else if (aliasScore >= descScore) {
matchType = 'alias'
} else {
matchType = 'description'
}
Expand All @@ -125,6 +165,8 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
return 'Exact match'
case 'prefix':
return 'Starts with'
case 'alias':
return 'Similar to'
case 'word-boundary':
return 'Word match'
case 'substring':
Expand Down
Loading