feat(web): restore snippet selection hook

This commit is contained in:
JzoNg 2026-06-24 17:48:33 +08:00
parent f7a0fcea9e
commit 9f814ec267

View File

@ -0,0 +1,329 @@
import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { useCallback, useState } from 'react'
import { getNodesBounds } from 'reactflow'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippet } from './use-create-snippet'
const DEFAULT_SNIPPET_VIEWPORT = { x: 0, y: 0, zoom: 1 }
const SNIPPET_INPUT_FIELD_NODE_ID = 'start'
const SNIPPET_VIEWPORT_WIDTH = 1200
const SNIPPET_VIEWPORT_HEIGHT = 800
const SNIPPET_VIEWPORT_PADDING = 160
const VARIABLE_REFERENCE_REGEX = /\{\{#([^#{}]+)#\}\}/g
const RESERVED_VARIABLE_PREFIXES = new Set(['rag'])
const isRecord = (value: unknown): value is Record<string, unknown> => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const isValueSelector = (value: unknown): value is ValueSelector => {
return Array.isArray(value)
&& value.length > 0
&& value.every(item => typeof item === 'string')
}
const isSelectorKey = (key?: string) => {
return key === 'selector' || !!key?.endsWith('_selector')
}
const isValueSelectorListKey = (key?: string) => {
return key === 'variables'
}
const isValueSelectorList = (value: unknown[]) => {
return value.length > 0 && value.every(isValueSelector)
}
const isContextPlaceholderSelector = (selector: ValueSelector) => {
return (selector.length === 1 && selector[0] === 'context')
|| selector.at(-1) === '#context#'
}
const getCenteredViewport = (nodes: Node[]) => {
if (!nodes.length)
return DEFAULT_SNIPPET_VIEWPORT
const bounds = getNodesBounds(nodes)
if (!bounds.width || !bounds.height)
return DEFAULT_SNIPPET_VIEWPORT
const zoom = Math.min(
(SNIPPET_VIEWPORT_WIDTH - SNIPPET_VIEWPORT_PADDING * 2) / bounds.width,
(SNIPPET_VIEWPORT_HEIGHT - SNIPPET_VIEWPORT_PADDING * 2) / bounds.height,
1,
)
const centerX = bounds.x + bounds.width / 2
const centerY = bounds.y + bounds.height / 2
return {
x: SNIPPET_VIEWPORT_WIDTH / 2 - centerX * zoom,
y: SNIPPET_VIEWPORT_HEIGHT / 2 - centerY * zoom,
zoom,
}
}
const collectSelectorsFromText = (value: string, selectors: ValueSelector[]) => {
for (const match of value.matchAll(VARIABLE_REFERENCE_REGEX)) {
const variablePath = match[1]
if (!variablePath)
continue
const selector = variablePath.split('.').filter(Boolean)
if (selector.length > 0 && !isContextPlaceholderSelector(selector))
selectors.push(selector)
}
}
const collectVariableSelectors = (
value: unknown,
selectors: ValueSelector[],
key?: string,
) => {
if (typeof value === 'string') {
collectSelectorsFromText(value, selectors)
return
}
if (Array.isArray(value)) {
if (isSelectorKey(key) && isValueSelector(value))
selectors.push(value)
if (isValueSelectorListKey(key) && isValueSelectorList(value)) {
value.forEach(selector => selectors.push(selector))
return
}
value.forEach(item => collectVariableSelectors(item, selectors))
return
}
if (!isRecord(value))
return
Object.entries(value).forEach(([currentKey, currentValue]) => {
collectVariableSelectors(currentValue, selectors, currentKey)
})
}
const isExternalVariableSelector = (
selector: ValueSelector,
selectedNodeIds: Set<string>,
) => {
const nodeId = selector[0]
if (!nodeId)
return false
if (nodeId.startsWith('$'))
return false
if (isContextPlaceholderSelector(selector))
return false
if (selectedNodeIds.has(nodeId))
return false
return !RESERVED_VARIABLE_PREFIXES.has(nodeId)
}
const sanitizeInputFieldVariable = (variable: string) => {
const sanitized = variable.replace(/\W/g, '_')
if (!sanitized)
return 'input'
if (/^\d/.test(sanitized))
return `input_${sanitized}`
return sanitized
}
const getUniqueInputFieldVariable = (
selector: ValueSelector,
usedVariables: Set<string>,
) => {
const baseVariable = sanitizeInputFieldVariable(selector.at(-1) ?? 'input')
let variable = baseVariable
let index = 2
while (usedVariables.has(variable)) {
variable = `${baseVariable}_${index}`
index += 1
}
usedVariables.add(variable)
return variable
}
const getInputFieldType = (selector: ValueSelector) => {
const variable = selector.at(-1)
if (variable === 'files')
return PipelineInputVarType.multiFiles
return PipelineInputVarType.textInput
}
const getExternalVariableInputFields = (
nodes: Node[],
selectedNodeIds: Set<string>,
) => {
const selectors: ValueSelector[] = []
nodes.forEach(node => collectVariableSelectors(node.data, selectors))
const usedVariables = new Set<string>()
const fieldBySelector = new Map<string, SnippetInputField>()
selectors.forEach((selector) => {
if (!isExternalVariableSelector(selector, selectedNodeIds))
return
const selectorKey = selector.join('.')
if (fieldBySelector.has(selectorKey))
return
const variable = getUniqueInputFieldVariable(selector, usedVariables)
fieldBySelector.set(selectorKey, {
label: variable,
variable,
type: getInputFieldType(selector),
required: true,
})
})
return {
inputFields: [...fieldBySelector.values()],
selectorMap: new Map(
[...fieldBySelector.entries()].map(([selectorKey, field]) => [
selectorKey,
[SNIPPET_INPUT_FIELD_NODE_ID, field.variable] satisfies ValueSelector,
]),
),
}
}
const rewriteVariableReferences = (
value: unknown,
selectorMap: Map<string, ValueSelector>,
key?: string,
): unknown => {
if (typeof value === 'string') {
return value.replace(VARIABLE_REFERENCE_REGEX, (match, variablePath: string) => {
const nextSelector = selectorMap.get(variablePath)
if (!nextSelector)
return match
return `{{#${nextSelector.join('.')}#}}`
})
}
if (Array.isArray(value)) {
if (isSelectorKey(key) && isValueSelector(value)) {
const nextSelector = selectorMap.get(value.join('.'))
if (nextSelector)
return nextSelector
}
if (isValueSelectorListKey(key) && isValueSelectorList(value)) {
return value.map((selector) => {
const nextSelector = selectorMap.get(selector.join('.'))
return nextSelector || selector
})
}
return value.map(item => rewriteVariableReferences(item, selectorMap))
}
if (!isRecord(value))
return value
return Object.fromEntries(
Object.entries(value).map(([currentKey, currentValue]) => [
currentKey,
rewriteVariableReferences(currentValue, selectorMap, currentKey),
]),
)
}
const getSelectedSnippetGraph = (selectedNodes: Node[], edges: Edge[]) => {
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
const {
inputFields,
selectorMap,
} = getExternalVariableInputFields(selectedNodes, selectedNodeIds)
const nodes = selectedNodes.map(node => ({
...node,
data: rewriteVariableReferences(node.data, selectorMap) as Node['data'],
selected: false,
}))
return {
graph: {
nodes,
edges: edges
.filter(edge => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target))
.map(edge => ({
...edge,
selected: false,
})),
viewport: getCenteredViewport(nodes),
} satisfies SnippetCanvasData,
inputFields,
}
}
type UseCreateSnippetFromSelectionParams = {
edges: Edge[]
selectedNodes: Node[]
onClose: () => void
}
export const useCreateSnippetFromSelection = ({
edges,
selectedNodes,
onClose,
}: UseCreateSnippetFromSelectionParams) => {
const [selectedSnippetGraph, setSelectedSnippetGraph] = useState<SnippetCanvasData>()
const [selectedSnippetInputFields, setSelectedSnippetInputFields] = useState<SnippetInputField[]>([])
const {
createSnippetMutation,
handleCloseCreateSnippetDialog,
handleCreateSnippet,
handleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen,
isCreatingSnippet,
} = useCreateSnippet()
const handleOpenCreateSnippet = useCallback(() => {
const {
graph,
inputFields,
} = getSelectedSnippetGraph(selectedNodes, edges)
setSelectedSnippetGraph(graph)
setSelectedSnippetInputFields(inputFields)
handleOpenCreateSnippetDialog()
onClose()
}, [edges, handleOpenCreateSnippetDialog, onClose, selectedNodes])
const handleCloseCreateSnippet = useCallback(() => {
setSelectedSnippetGraph(undefined)
setSelectedSnippetInputFields([])
handleCloseCreateSnippetDialog()
}, [handleCloseCreateSnippetDialog])
const createSnippetDialog = (
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
selectedGraph={selectedSnippetGraph}
inputFields={selectedSnippetInputFields}
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
onClose={handleCloseCreateSnippet}
onConfirm={handleCreateSnippet}
/>
)
return {
createSnippetDialog,
handleOpenCreateSnippet,
isCreateSnippetDialogOpen,
}
}