mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 08:18:09 -05:00
Compare commits
9 Commits
fix/keyboa
...
fix/cmdk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0603101d75 | ||
|
|
c4d0fc31cc | ||
|
|
19bc4afcc9 | ||
|
|
6901b15260 | ||
|
|
fe72c69d44 | ||
|
|
1709e1f81f | ||
|
|
e02c156d75 | ||
|
|
9506fea20d | ||
|
|
6494f614b4 |
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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<T extends SearchableItem> {
|
||||
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<T extends SearchableItem>(
|
||||
query: string,
|
||||
items: T[]
|
||||
): SearchResult<T>[] {
|
||||
const normalizedQuery = query.trim()
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: SearchResult<T>[] = []
|
||||
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<T>['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<any>['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'
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ function FormattedInput({
|
||||
onChange,
|
||||
onScroll,
|
||||
}: FormattedInputProps) {
|
||||
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
|
||||
const handleScroll = (e: { currentTarget: HTMLInputElement }) => {
|
||||
onScroll(e.currentTarget.scrollLeft)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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<SearchModalState>((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<SearchModalState>()(
|
||||
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.serviceName}: ${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' }
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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: <T extends { type: string }>(blocks: T[]) => T[]) => void
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user