Skip to content

Commit 3c43779

Browse files
authored
feat(search): added operations to search modal in main app, updated retrieval in docs to use RRF (#2889)
1 parent 1861f77 commit 3c43779

File tree

7 files changed

+425
-64
lines changed

7 files changed

+425
-64
lines changed

apps/docs/app/api/search/route.ts

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,27 +86,112 @@ export async function GET(request: NextRequest) {
8686
)
8787
.limit(candidateLimit)
8888

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

92-
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
93-
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
94-
mergedResults.push(vectorResults[i])
95-
seenIds.add(vectorResults[i].chunkId)
91+
const vectorRankMap = new Map<string, number>()
92+
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
93+
94+
const keywordRankMap = new Map<string, number>()
95+
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
96+
97+
const allChunkIds = new Set([
98+
...vectorResults.map((r) => r.chunkId),
99+
...keywordResults.map((r) => r.chunkId),
100+
])
101+
102+
const k = 60
103+
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
104+
const scoredResults: ResultWithRRF[] = []
105+
106+
for (const chunkId of allChunkIds) {
107+
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
108+
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
109+
110+
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
111+
112+
const result =
113+
vectorResults.find((r) => r.chunkId === chunkId) ||
114+
keywordResults.find((r) => r.chunkId === chunkId)
115+
116+
if (result) {
117+
scoredResults.push({ ...result, rrfScore })
96118
}
97-
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
98-
mergedResults.push(keywordResults[i])
99-
seenIds.add(keywordResults[i].chunkId)
119+
}
120+
121+
scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
122+
123+
const localeFilteredResults = scoredResults.filter((result) => {
124+
const firstPart = result.sourceDocument.split('/')[0]
125+
if (knownLocales.includes(firstPart)) {
126+
return firstPart === locale
127+
}
128+
return locale === 'en'
129+
})
130+
131+
const queryLower = query.toLowerCase()
132+
const getTitleBoost = (result: ResultWithRRF): number => {
133+
const fileName = result.sourceDocument
134+
.replace('.mdx', '')
135+
.split('/')
136+
.pop()
137+
?.toLowerCase()
138+
?.replace(/_/g, ' ')
139+
140+
if (fileName === queryLower) return 0.01
141+
if (fileName?.includes(queryLower)) return 0.005
142+
return 0
143+
}
144+
145+
localeFilteredResults.sort((a, b) => {
146+
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
147+
})
148+
149+
const pageMap = new Map<string, ResultWithRRF>()
150+
151+
for (const result of localeFilteredResults) {
152+
const pageKey = result.sourceDocument
153+
const existing = pageMap.get(pageKey)
154+
155+
if (!existing || result.rrfScore > existing.rrfScore) {
156+
pageMap.set(pageKey, result)
100157
}
101158
}
102159

103-
const filteredResults = mergedResults.slice(0, limit)
104-
const searchResults = filteredResults.map((result) => {
160+
const deduplicatedResults = Array.from(pageMap.values())
161+
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
162+
.slice(0, limit)
163+
164+
const searchResults = deduplicatedResults.map((result) => {
105165
const title = result.headerText || result.sourceDocument.replace('.mdx', '')
166+
106167
const pathParts = result.sourceDocument
107168
.replace('.mdx', '')
108169
.split('/')
109-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
170+
.filter((part) => part !== 'index' && !knownLocales.includes(part))
171+
.map((part) => {
172+
return part
173+
.replace(/_/g, ' ')
174+
.split(' ')
175+
.map((word) => {
176+
const acronyms = [
177+
'api',
178+
'mcp',
179+
'sdk',
180+
'url',
181+
'http',
182+
'json',
183+
'xml',
184+
'html',
185+
'css',
186+
'ai',
187+
]
188+
if (acronyms.includes(word.toLowerCase())) {
189+
return word.toUpperCase()
190+
}
191+
return word.charAt(0).toUpperCase() + word.slice(1)
192+
})
193+
.join(' ')
194+
})
110195

111196
return {
112197
id: result.chunkId,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,8 @@ const WorkflowContent = React.memo(() => {
692692
parentId?: string,
693693
extent?: 'parent',
694694
autoConnectEdge?: Edge,
695-
triggerMode?: boolean
695+
triggerMode?: boolean,
696+
presetSubBlockValues?: Record<string, unknown>
696697
) => {
697698
setPendingSelection([id])
698699
setSelectedEdges(new Map())
@@ -722,6 +723,14 @@ const WorkflowContent = React.memo(() => {
722723
}
723724
}
724725

726+
// Apply preset subblock values (e.g., from tool-operation search)
727+
if (presetSubBlockValues) {
728+
if (!subBlockValues[id]) {
729+
subBlockValues[id] = {}
730+
}
731+
Object.assign(subBlockValues[id], presetSubBlockValues)
732+
}
733+
725734
collaborativeBatchAddBlocks(
726735
[block],
727736
autoConnectEdge ? [autoConnectEdge] : [],
@@ -1489,7 +1498,7 @@ const WorkflowContent = React.memo(() => {
14891498
return
14901499
}
14911500

1492-
const { type, enableTriggerMode } = event.detail
1501+
const { type, enableTriggerMode, presetOperation } = event.detail
14931502

14941503
if (!type) return
14951504
if (type === 'connectionBlock') return
@@ -1552,7 +1561,8 @@ const WorkflowContent = React.memo(() => {
15521561
undefined,
15531562
undefined,
15541563
autoConnectEdge,
1555-
enableTriggerMode
1564+
enableTriggerMode,
1565+
presetOperation ? { operation: presetOperation } : undefined
15561566
)
15571567
}
15581568

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation'
88
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
99
import { useBrandConfig } from '@/lib/branding/branding'
1010
import { cn } from '@/lib/core/utils/cn'
11+
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
1112
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
1213
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
1314
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -81,10 +82,12 @@ type SearchItem = {
8182
color?: string
8283
href?: string
8384
shortcut?: string
84-
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
85+
type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
8586
isCurrent?: boolean
8687
blockType?: string
8788
config?: any
89+
operationId?: string
90+
aliases?: string[]
8891
}
8992

9093
interface SearchResultItemProps {
@@ -101,7 +104,11 @@ const SearchResultItem = memo(function SearchResultItem({
101104
onItemClick,
102105
}: SearchResultItemProps) {
103106
const Icon = item.icon
104-
const showColoredIcon = item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
107+
const showColoredIcon =
108+
item.type === 'block' ||
109+
item.type === 'trigger' ||
110+
item.type === 'tool' ||
111+
item.type === 'tool-operation'
105112
const isWorkflow = item.type === 'workflow'
106113
const isWorkspace = item.type === 'workspace'
107114

@@ -278,6 +285,24 @@ export const SearchModal = memo(function SearchModal({
278285
)
279286
}, [open, isOnWorkflowPage, filterBlocks])
280287

288+
const toolOperations = useMemo(() => {
289+
if (!open || !isOnWorkflowPage) return []
290+
291+
const allowedBlockTypes = new Set(tools.map((t) => t.type))
292+
293+
return getToolOperationsIndex()
294+
.filter((op) => allowedBlockTypes.has(op.blockType))
295+
.map((op) => ({
296+
id: op.id,
297+
name: `${op.serviceName}: ${op.operationName}`,
298+
icon: op.icon,
299+
bgColor: op.bgColor,
300+
blockType: op.blockType,
301+
operationId: op.operationId,
302+
aliases: op.aliases,
303+
}))
304+
}, [open, isOnWorkflowPage, tools])
305+
281306
const pages = useMemo(
282307
(): PageItem[] => [
283308
{
@@ -396,6 +421,19 @@ export const SearchModal = memo(function SearchModal({
396421
})
397422
})
398423

424+
toolOperations.forEach((op) => {
425+
items.push({
426+
id: op.id,
427+
name: op.name,
428+
icon: op.icon,
429+
bgColor: op.bgColor,
430+
type: 'tool-operation',
431+
blockType: op.blockType,
432+
operationId: op.operationId,
433+
aliases: op.aliases,
434+
})
435+
})
436+
399437
docs.forEach((doc) => {
400438
items.push({
401439
id: doc.id,
@@ -407,10 +445,10 @@ export const SearchModal = memo(function SearchModal({
407445
})
408446

409447
return items
410-
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
448+
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
411449

412450
const sectionOrder = useMemo<SearchItem['type'][]>(
413-
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
451+
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
414452
[]
415453
)
416454

@@ -457,6 +495,7 @@ export const SearchModal = memo(function SearchModal({
457495
page: [],
458496
trigger: [],
459497
block: [],
498+
'tool-operation': [],
460499
tool: [],
461500
doc: [],
462501
}
@@ -512,6 +551,17 @@ export const SearchModal = memo(function SearchModal({
512551
window.dispatchEvent(event)
513552
}
514553
break
554+
case 'tool-operation':
555+
if (item.blockType && item.operationId) {
556+
const event = new CustomEvent('add-block-from-toolbar', {
557+
detail: {
558+
type: item.blockType,
559+
presetOperation: item.operationId,
560+
},
561+
})
562+
window.dispatchEvent(event)
563+
}
564+
break
515565
case 'workspace':
516566
if (item.isCurrent) {
517567
break
@@ -592,6 +642,7 @@ export const SearchModal = memo(function SearchModal({
592642
page: 'Pages',
593643
trigger: 'Triggers',
594644
block: 'Blocks',
645+
'tool-operation': 'Tool Operations',
595646
tool: 'Tools',
596647
doc: 'Docs',
597648
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@ export interface SearchableItem {
88
name: string
99
description?: string
1010
type: string
11+
aliases?: string[]
1112
[key: string]: any
1213
}
1314

1415
export interface SearchResult<T extends SearchableItem> {
1516
item: T
1617
score: number
17-
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description'
18+
matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
1819
}
1920

2021
const SCORE_EXACT_MATCH = 10000
2122
const SCORE_PREFIX_MATCH = 5000
23+
const SCORE_ALIAS_MATCH = 3000
2224
const SCORE_WORD_BOUNDARY = 1000
2325
const SCORE_SUBSTRING_MATCH = 100
2426
const DESCRIPTION_WEIGHT = 0.3
@@ -67,6 +69,39 @@ function calculateFieldScore(
6769
return { score: 0, matchType: null }
6870
}
6971

72+
/**
73+
* Check if query matches any alias in the item's aliases array
74+
* Returns the alias score if a match is found, 0 otherwise
75+
*/
76+
function calculateAliasScore(
77+
query: string,
78+
aliases?: string[]
79+
): { score: number; matchType: 'alias' | null } {
80+
if (!aliases || aliases.length === 0) {
81+
return { score: 0, matchType: null }
82+
}
83+
84+
const normalizedQuery = query.toLowerCase().trim()
85+
86+
for (const alias of aliases) {
87+
const normalizedAlias = alias.toLowerCase().trim()
88+
89+
if (normalizedAlias === normalizedQuery) {
90+
return { score: SCORE_ALIAS_MATCH, matchType: 'alias' }
91+
}
92+
93+
if (normalizedAlias.startsWith(normalizedQuery)) {
94+
return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' }
95+
}
96+
97+
if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) {
98+
return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' }
99+
}
100+
}
101+
102+
return { score: 0, matchType: null }
103+
}
104+
70105
/**
71106
* Search items using tiered matching algorithm
72107
* Returns items sorted by relevance (highest score first)
@@ -90,15 +125,20 @@ export function searchItems<T extends SearchableItem>(
90125
? calculateFieldScore(normalizedQuery, item.description)
91126
: { score: 0, matchType: null }
92127

128+
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
129+
93130
const nameScore = nameMatch.score
94131
const descScore = descMatch.score * DESCRIPTION_WEIGHT
132+
const aliasScore = aliasMatch.score
95133

96-
const bestScore = Math.max(nameScore, descScore)
134+
const bestScore = Math.max(nameScore, descScore, aliasScore)
97135

98136
if (bestScore > 0) {
99137
let matchType: SearchResult<T>['matchType'] = 'substring'
100-
if (nameScore >= descScore) {
138+
if (nameScore >= descScore && nameScore >= aliasScore) {
101139
matchType = nameMatch.matchType || 'substring'
140+
} else if (aliasScore >= descScore) {
141+
matchType = 'alias'
102142
} else {
103143
matchType = 'description'
104144
}
@@ -125,6 +165,8 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
125165
return 'Exact match'
126166
case 'prefix':
127167
return 'Starts with'
168+
case 'alias':
169+
return 'Similar to'
128170
case 'word-boundary':
129171
return 'Word match'
130172
case 'substring':

0 commit comments

Comments
 (0)