mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(search-replace): dedupe double indexed segments (#4517)
* improvement(search-replace): dedupe double indexed segments * cleanup error message * address comments
This commit is contained in:
@@ -126,10 +126,6 @@ export const PATCH = withRouteHandler(
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowId && workflowId !== existingChat[0].workflowId) {
|
||||
return createErrorResponse('Changing a chat deployment workflow is not supported', 400)
|
||||
}
|
||||
|
||||
let encryptedPassword
|
||||
|
||||
if (password) {
|
||||
|
||||
+5
-1
@@ -9,6 +9,7 @@ import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace
|
||||
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
|
||||
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
|
||||
import {
|
||||
dedupeOverlappingWorkflowSearchMatches,
|
||||
getCompatibleResourceReplacementOptions,
|
||||
getWorkflowSearchCompatibleResourceMatches,
|
||||
getWorkflowSearchMatchResourceGroupKey,
|
||||
@@ -197,7 +198,10 @@ export function WorkflowSearchReplace() {
|
||||
})
|
||||
|
||||
const hydratedMatches = useMemo(
|
||||
() => allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query)),
|
||||
() =>
|
||||
dedupeOverlappingWorkflowSearchMatches(
|
||||
allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query))
|
||||
),
|
||||
[allHydratedMatches, query]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dedupeOverlappingWorkflowSearchMatches } from '@/lib/workflows/search-replace/resources/resolvers'
|
||||
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
|
||||
|
||||
function createMatch(overrides: Partial<WorkflowSearchMatch>): WorkflowSearchMatch {
|
||||
return {
|
||||
id: 'match',
|
||||
blockId: 'block-1',
|
||||
blockName: 'Block',
|
||||
blockType: 'function',
|
||||
subBlockId: 'code',
|
||||
canonicalSubBlockId: 'code',
|
||||
subBlockType: 'code',
|
||||
valuePath: [],
|
||||
target: { kind: 'subblock' },
|
||||
kind: 'text',
|
||||
rawValue: '',
|
||||
searchText: '',
|
||||
editable: true,
|
||||
navigable: true,
|
||||
protected: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dedupeOverlappingWorkflowSearchMatches', () => {
|
||||
it('keeps the narrower text hit when a partial literal query overlaps an inline reference', () => {
|
||||
const textMatch = createMatch({
|
||||
id: 'text-partial',
|
||||
kind: 'text',
|
||||
rawValue: '<start.h',
|
||||
searchText: "return '<start.hello>'",
|
||||
range: { start: 8, end: 16 },
|
||||
})
|
||||
const referenceMatch = createMatch({
|
||||
id: 'workflow-reference',
|
||||
kind: 'workflow-reference',
|
||||
rawValue: '<start.hello>',
|
||||
searchText: 'start.hello',
|
||||
range: { start: 8, end: 21 },
|
||||
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
|
||||
})
|
||||
|
||||
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([textMatch])
|
||||
})
|
||||
|
||||
it('keeps the inline reference when it covers the same span as a text hit', () => {
|
||||
const textMatch = createMatch({
|
||||
id: 'text-full',
|
||||
kind: 'text',
|
||||
rawValue: '<start.hello>',
|
||||
searchText: "return '<start.hello>'",
|
||||
range: { start: 8, end: 21 },
|
||||
})
|
||||
const referenceMatch = createMatch({
|
||||
id: 'workflow-reference',
|
||||
kind: 'workflow-reference',
|
||||
rawValue: '<start.hello>',
|
||||
searchText: 'start.hello',
|
||||
range: { start: 8, end: 21 },
|
||||
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
|
||||
})
|
||||
|
||||
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([
|
||||
referenceMatch,
|
||||
])
|
||||
})
|
||||
|
||||
it('uses kind priority rather than iteration order for equal-span non-text matches', () => {
|
||||
const workflowReferenceMatch = createMatch({
|
||||
id: 'workflow-reference',
|
||||
kind: 'workflow-reference',
|
||||
rawValue: '{{API_KEY}}',
|
||||
searchText: 'API_KEY',
|
||||
range: { start: 0, end: 11 },
|
||||
resource: { kind: 'workflow-reference', token: '{{API_KEY}}', key: 'API_KEY' },
|
||||
})
|
||||
const environmentMatch = createMatch({
|
||||
id: 'environment',
|
||||
kind: 'environment',
|
||||
rawValue: '{{API_KEY}}',
|
||||
searchText: 'API_KEY',
|
||||
range: { start: 0, end: 11 },
|
||||
resource: { kind: 'environment', token: '{{API_KEY}}', key: 'API_KEY' },
|
||||
})
|
||||
|
||||
expect(
|
||||
dedupeOverlappingWorkflowSearchMatches([workflowReferenceMatch, environmentMatch])
|
||||
).toEqual([workflowReferenceMatch])
|
||||
expect(
|
||||
dedupeOverlappingWorkflowSearchMatches([environmentMatch, workflowReferenceMatch])
|
||||
).toEqual([workflowReferenceMatch])
|
||||
})
|
||||
|
||||
it('does not collapse matches from different fields', () => {
|
||||
const firstMatch = createMatch({
|
||||
id: 'first',
|
||||
range: { start: 0, end: 4 },
|
||||
valuePath: ['first'],
|
||||
})
|
||||
const secondMatch = createMatch({
|
||||
id: 'second',
|
||||
range: { start: 0, end: 4 },
|
||||
valuePath: ['second'],
|
||||
})
|
||||
|
||||
expect(dedupeOverlappingWorkflowSearchMatches([firstMatch, secondMatch])).toEqual([
|
||||
firstMatch,
|
||||
secondMatch,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,27 @@
|
||||
import type {
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchMatchKind,
|
||||
WorkflowSearchReplacementOption,
|
||||
WorkflowSearchResourceMeta,
|
||||
WorkflowSearchValuePath,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
const OVERLAPPING_MATCH_KIND_PRIORITY: Record<WorkflowSearchMatchKind, number> = {
|
||||
text: 0,
|
||||
environment: 1,
|
||||
'workflow-reference': 2,
|
||||
'oauth-credential': 3,
|
||||
'knowledge-base': 3,
|
||||
'knowledge-document': 3,
|
||||
workflow: 3,
|
||||
'mcp-server': 3,
|
||||
'mcp-tool': 3,
|
||||
table: 3,
|
||||
file: 3,
|
||||
'selector-resource': 3,
|
||||
}
|
||||
|
||||
export function stableStringifyWorkflowSearchValue(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) {
|
||||
@@ -88,6 +105,73 @@ export function getWorkflowSearchCompatibleResourceMatches(
|
||||
)
|
||||
}
|
||||
|
||||
function searchValuePathKey(path: WorkflowSearchValuePath): string {
|
||||
return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/')
|
||||
}
|
||||
|
||||
function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null {
|
||||
if (!match.range) return null
|
||||
if (match.target.kind !== 'subblock') return null
|
||||
return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':')
|
||||
}
|
||||
|
||||
function rangesOverlap(
|
||||
left: NonNullable<WorkflowSearchMatch['range']>,
|
||||
right: NonNullable<WorkflowSearchMatch['range']>
|
||||
): boolean {
|
||||
return left.start < right.end && right.start < left.end
|
||||
}
|
||||
|
||||
function getRangeLength(match: WorkflowSearchMatch): number {
|
||||
return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function shouldPreferOverlappingMatch(
|
||||
candidate: WorkflowSearchMatch,
|
||||
current: WorkflowSearchMatch
|
||||
): boolean {
|
||||
const candidateLength = getRangeLength(candidate)
|
||||
const currentLength = getRangeLength(current)
|
||||
if (candidateLength !== currentLength) return candidateLength < currentLength
|
||||
|
||||
const candidatePriority = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind]
|
||||
const currentPriority = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind]
|
||||
if (candidatePriority !== currentPriority) return candidatePriority > currentPriority
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function dedupeOverlappingWorkflowSearchMatches<T extends WorkflowSearchMatch>(
|
||||
matches: T[]
|
||||
): T[] {
|
||||
const deduped: T[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
const scopeKey = getRangeMatchScopeKey(match)
|
||||
const matchRange = match.range
|
||||
const existingIndex =
|
||||
scopeKey && matchRange
|
||||
? deduped.findIndex(
|
||||
(candidate) =>
|
||||
getRangeMatchScopeKey(candidate) === scopeKey &&
|
||||
candidate.range &&
|
||||
rangesOverlap(candidate.range, matchRange)
|
||||
)
|
||||
: -1
|
||||
|
||||
if (existingIndex === -1) {
|
||||
deduped.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) {
|
||||
deduped[existingIndex] = match
|
||||
}
|
||||
}
|
||||
|
||||
return deduped
|
||||
}
|
||||
|
||||
export function workflowSearchMatchMatchesQuery(
|
||||
match: WorkflowSearchMatch & { displayLabel?: string },
|
||||
query: string,
|
||||
|
||||
Reference in New Issue
Block a user