Merge branch 'feat/refine-snippet-siderbar' into deploy/dev

This commit is contained in:
JzoNg 2026-06-24 17:59:31 +08:00
commit a1c97ba21a
3 changed files with 604 additions and 18 deletions

View File

@ -34,11 +34,8 @@ from controllers.console.app.workflow_draft_variable import (
)
from controllers.console.snippets.snippet_workflow import get_snippet
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_user,
)
@ -128,9 +125,6 @@ class SnippetWorkflowVariableCollectionApi(Resource):
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
@console_ns.response(204, "Workflow variables deleted successfully")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)
@ -157,9 +151,6 @@ class SnippetNodeVariableCollectionApi(Resource):
@console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)")
@console_ns.response(204, "Node variables deleted successfully")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, current_user: Account, snippet: CustomizedSnippet, node_id: str) -> Response:
validate_node_id(node_id)
srv = WorkflowDraftVariableService(db.session())
@ -194,9 +185,6 @@ class SnippetVariableApi(Resource):
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_model)
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def patch(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {})
@ -244,9 +232,6 @@ class SnippetVariableApi(Resource):
@console_ns.response(204, "Variable deleted successfully")
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> Response:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
variable = ensure_variable_access(
@ -269,9 +254,6 @@ class SnippetVariableResetApi(Resource):
@console_ns.response(204, "Variable reset (no content)")
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def put(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> Response | Any:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
snippet_service = _snippet_service()

View File

@ -0,0 +1,275 @@
import type { ReactElement } from 'react'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippetFromSelection } from '../use-create-snippet-from-selection'
const SNIPPET_INPUT_FIELD_NODE_ID = 'start'
const mockHandleOpenCreateSnippetDialog = vi.fn()
const mockHandleCloseCreateSnippetDialog = vi.fn()
const mockHandleCreateSnippet = vi.fn()
vi.mock('../use-create-snippet', () => ({
useCreateSnippet: () => ({
createSnippetMutation: {
isPending: false,
},
handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog,
handleCreateSnippet: mockHandleCreateSnippet,
handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen: true,
isCreatingSnippet: false,
}),
}))
type DialogProps = {
selectedGraph?: SnippetCanvasData
inputFields?: SnippetInputField[]
}
const createNode = (
id: string,
data: Record<string, unknown>,
): Node => ({
id,
type: 'custom',
position: { x: 0, y: 0 },
width: 200,
height: 100,
data,
} as unknown as Node)
describe('useCreateSnippetFromSelection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should convert environment, conversation, and system variables into snippet input fields', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
prompt: [
'{{#env.API_KEY#}}',
'{{#conversation.user_name#}}',
'{{#sys.user_id#}}',
'{{#rag.query#}}',
'{{#source.result#}}',
].join(' '),
model_selector: ['env', 'MODEL_NAME'],
}),
]
const edges: Edge[] = []
const onClose = vi.fn()
const { result } = renderHook(() => useCreateSnippetFromSelection({
edges,
selectedNodes,
onClose,
}))
act(() => {
result.current.handleOpenCreateSnippet()
})
const dialogProps = (result.current.createSnippetDialog as ReactElement<DialogProps>).props
expect(dialogProps.inputFields).toEqual([
{
label: 'API_KEY',
variable: 'API_KEY',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'user_name',
variable: 'user_name',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'user_id',
variable: 'user_id',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'result',
variable: 'result',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'MODEL_NAME',
variable: 'MODEL_NAME',
type: PipelineInputVarType.textInput,
required: true,
},
])
const nodeData = dialogProps.selectedGraph?.nodes[0]?.data as Record<string, unknown> | undefined
expect(nodeData?.prompt).toBe([
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.API_KEY#}}`,
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_name#}}`,
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_id#}}`,
'{{#rag.query#}}',
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.result#}}`,
].join(' '))
expect(nodeData?.model_selector).toEqual([
SNIPPET_INPUT_FIELD_NODE_ID,
'MODEL_NAME',
])
expect(onClose).toHaveBeenCalled()
})
it('should convert system variables used by if-else and variable aggregator nodes', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
title: 'LLM',
}),
createNode('if-else', {
type: BlockEnum.IfElse,
cases: [{
case_id: 'case-1',
conditions: [{
id: 'condition-1',
variable_selector: ['sys', 'query'],
comparison_operator: 'contains',
value: 'hello',
}],
}],
}),
createNode('variable-aggregator', {
type: BlockEnum.VariableAggregator,
variables: [
['sys', 'files'],
['llm', 'text'],
],
advanced_settings: {
group_enabled: true,
groups: [{
groupId: 'group-1',
group_name: 'Group1',
variables: [
['sys', 'workflow_id'],
],
}],
},
}),
]
const onClose = vi.fn()
const { result } = renderHook(() => useCreateSnippetFromSelection({
edges: [],
selectedNodes,
onClose,
}))
act(() => {
result.current.handleOpenCreateSnippet()
})
const dialogProps = (result.current.createSnippetDialog as ReactElement<DialogProps>).props
expect(dialogProps.inputFields).toEqual([
{
label: 'query',
variable: 'query',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'files',
variable: 'files',
type: PipelineInputVarType.multiFiles,
required: true,
},
{
label: 'workflow_id',
variable: 'workflow_id',
type: PipelineInputVarType.textInput,
required: true,
},
])
const ifElseNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'if-else')
const aggregatorNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'variable-aggregator')
const ifElseData = ifElseNode?.data as Record<string, unknown>
const aggregatorData = aggregatorNode?.data as {
variables?: string[][]
advanced_settings?: { groups?: Array<{ variables?: string[][] }> }
}
expect(ifElseData.cases).toEqual([{
case_id: 'case-1',
conditions: [{
id: 'condition-1',
variable_selector: [SNIPPET_INPUT_FIELD_NODE_ID, 'query'],
comparison_operator: 'contains',
value: 'hello',
}],
}])
expect(aggregatorData.variables).toEqual([
[SNIPPET_INPUT_FIELD_NODE_ID, 'files'],
['llm', 'text'],
])
expect(aggregatorData.advanced_settings?.groups?.[0]?.variables).toEqual([
[SNIPPET_INPUT_FIELD_NODE_ID, 'workflow_id'],
])
})
it('should keep #context# prompt placeholders when creating a snippet from workflow selection', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
context: {
enabled: true,
variable_selector: ['code', 'result'],
},
prompt: '{{#context#}} {{#code.summary#}}',
}),
]
const onClose = vi.fn()
const { result } = renderHook(() => useCreateSnippetFromSelection({
edges: [],
selectedNodes,
onClose,
}))
act(() => {
result.current.handleOpenCreateSnippet()
})
const dialogProps = (result.current.createSnippetDialog as ReactElement<DialogProps>).props
const nodeData = dialogProps.selectedGraph?.nodes[0]?.data as {
context?: {
enabled: boolean
variable_selector: string[]
}
prompt?: string
}
expect(dialogProps.inputFields).toEqual([
{
label: 'result',
variable: 'result',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'summary',
variable: 'summary',
type: PipelineInputVarType.textInput,
required: true,
},
])
expect(nodeData.context).toEqual({
enabled: true,
variable_selector: [SNIPPET_INPUT_FIELD_NODE_ID, 'result'],
})
expect(nodeData.prompt).toBe(`{{#context#}} {{#${SNIPPET_INPUT_FIELD_NODE_ID}.summary#}}`)
})
})

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,
}
}