فهرست منبع

feat: code transform node editor support insert var by add slash or left brace (#3946)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Joel 1 سال پیش
والد
کامیت
3e992cb23c

+ 8 - 0
web/app/components/workflow/header/run-and-history.tsx

@@ -38,6 +38,7 @@ const RunMode = memo(() => {
   const {
     doSyncWorkflowDraft,
   } = useNodesSyncDraft()
+  const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
   const workflowRunningData = useStore(s => s.workflowRunningData)
   const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
 
@@ -55,10 +56,16 @@ const RunMode = memo(() => {
     const startVariables = startNode?.data.variables || []
     const fileSettings = featuresStore!.getState().features.file
     const {
+      showDebugAndPreviewPanel,
       setShowDebugAndPreviewPanel,
       setShowInputsPanel,
     } = workflowStore.getState()
 
+    if (showDebugAndPreviewPanel) {
+      handleCancelDebugAndPreviewPanel()
+      return
+    }
+
     if (!startVariables.length && !fileSettings?.image?.enabled) {
       await doSyncWorkflowDraft()
       handleRun({ inputs: {}, files: [] })
@@ -75,6 +82,7 @@ const RunMode = memo(() => {
     doSyncWorkflowDraft,
     store,
     featuresStore,
+    handleCancelDebugAndPreviewPanel,
   ])
 
   return (

+ 1 - 0
web/app/components/workflow/hooks/use-workflow-interactions.ts

@@ -21,6 +21,7 @@ export const useWorkflowInteractions = () => {
   const handleCancelDebugAndPreviewPanel = useCallback(() => {
     workflowStore.setState({
       showDebugAndPreviewPanel: false,
+      workflowRunningData: undefined,
     })
     handleNodeCancelRunningStatus()
     handleEdgeCancelRunningStatus()

+ 173 - 0
web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx

@@ -0,0 +1,173 @@
+'use client'
+import type { FC } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
+import { useBoolean } from 'ahooks'
+import { useTranslation } from 'react-i18next'
+import type { Props as EditorProps } from '.'
+import Editor from '.'
+import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
+import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
+import type { Variable } from '@/app/components/workflow/types'
+
+const TO_WINDOW_OFFSET = 8
+
+type Props = {
+  nodeId: string
+  varList: Variable[]
+  onAddVar: (payload: Variable) => void
+} & EditorProps
+
+const CodeEditor: FC<Props> = ({
+  nodeId,
+  varList,
+  onAddVar,
+  ...editorProps
+}) => {
+  const { t } = useTranslation()
+
+  const { availableVars } = useAvailableVarList(nodeId, {
+    onlyLeafNodeVar: false,
+    filterVar: () => true,
+  })
+
+  const isLeftBraceRef = useRef(false)
+
+  const editorRef = useRef(null)
+  const monacoRef = useRef(null)
+
+  const popupRef = useRef<HTMLDivElement>(null)
+  const [isShowVarPicker, {
+    setTrue: showVarPicker,
+    setFalse: hideVarPicker,
+  }] = useBoolean(false)
+
+  const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
+
+  // Listen for cursor position changes
+  const handleCursorPositionChange = (event: any) => {
+    const editor: any = editorRef.current
+    const { position } = event
+    const text = editor.getModel().getLineContent(position.lineNumber)
+    const charBefore = text[position.column - 2]
+    if (['/', '{'].includes(charBefore)) {
+      isLeftBraceRef.current = charBefore === '{'
+      const editorRect = editor.getDomNode().getBoundingClientRect()
+      const cursorCoords = editor.getScrolledVisiblePosition(position)
+
+      const popupX = editorRect.left + cursorCoords.left
+      const popupY = editorRect.top + cursorCoords.top + 20 // Adjust the vertical position as needed
+
+      setPopupPosition({ x: popupX, y: popupY })
+      showVarPicker()
+    }
+    else {
+      hideVarPicker()
+    }
+  }
+
+  useEffect(() => {
+    if (isShowVarPicker && popupRef.current) {
+      const windowWidth = window.innerWidth
+      const { width, height } = popupRef.current!.getBoundingClientRect()
+      const newPopupPosition = { ...popupPosition }
+      if (popupPosition.x + width > windowWidth - TO_WINDOW_OFFSET)
+        newPopupPosition.x = windowWidth - width - TO_WINDOW_OFFSET
+
+      if (popupPosition.y + height > window.innerHeight - TO_WINDOW_OFFSET)
+        newPopupPosition.y = window.innerHeight - height - TO_WINDOW_OFFSET
+
+      setPopupPosition(newPopupPosition)
+    }
+  }, [isShowVarPicker, popupPosition])
+
+  const onEditorMounted = (editor: any, monaco: any) => {
+    editorRef.current = editor
+    monacoRef.current = monaco
+    editor.onDidChangeCursorPosition(handleCursorPositionChange)
+  }
+
+  const getUniqVarName = (varName: string) => {
+    if (varList.find(v => v.variable === varName)) {
+      const match = varName.match(/_([0-9]+)$/)
+
+      const index = (() => {
+        if (match)
+          return parseInt(match[1]!) + 1
+
+        return 1
+      })()
+      return getUniqVarName(`${varName.replace(/_([0-9]+)$/, '')}_${index}`)
+    }
+    return varName
+  }
+
+  const getVarName = (varValue: string[]) => {
+    const existVar = varList.find(v => Array.isArray(v.value_selector) && v.value_selector.join('@@@') === varValue.join('@@@'))
+    if (existVar) {
+      return {
+        name: existVar.variable,
+        isExist: true,
+      }
+    }
+    const varName = varValue.slice(-1)[0]
+    return {
+      name: getUniqVarName(varName),
+      isExist: false,
+    }
+  }
+
+  const handleSelectVar = (varValue: string[]) => {
+    const { name, isExist } = getVarName(varValue)
+    if (!isExist) {
+      const newVar: Variable = {
+        variable: name,
+        value_selector: varValue,
+      }
+
+      onAddVar(newVar)
+    }
+    const editor: any = editorRef.current
+    const monaco: any = monacoRef.current
+    const position = editor?.getPosition()
+
+    // Insert the content at the cursor position
+    editor?.executeEdits('', [
+      {
+        // position.column - 1 to remove the text before the cursor
+        range: new monaco.Range(position.lineNumber, position.column - 1, position.lineNumber, position.column),
+        text: `{{ ${name} }${!isLeftBraceRef.current ? '}' : ''}`, // left brace would auto add one right brace
+      },
+    ])
+
+    hideVarPicker()
+  }
+
+  return (
+    <div>
+      <Editor
+        {...editorProps}
+        onMount={onEditorMounted}
+        placeholder={t('workflow.common.jinjaEditorPlaceholder')!}
+      />
+      {isShowVarPicker && (
+        <div
+          ref={popupRef}
+          className='w-[228px] p-1 bg-white rounded-lg border border-gray-200 shadow-lg space-y-1'
+          style={{
+            position: 'fixed',
+            top: popupPosition.y,
+            left: popupPosition.x,
+            zIndex: 100,
+          }}
+        >
+          <VarReferenceVars
+            hideSearch
+            vars={availableVars}
+            onChange={handleSelectVar}
+          />
+        </div>
+      )}
+    </div>
+  )
+}
+export default React.memo(CodeEditor)

+ 11 - 1
web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx

@@ -1,6 +1,7 @@
 'use client'
 import type { FC } from 'react'
 import Editor, { loader } from '@monaco-editor/react'
+
 import React, { useRef } from 'react'
 import Base from '../base'
 import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@@ -9,8 +10,9 @@ import './style.css'
 // load file from local instead of cdn https://github.com/suren-atoyan/monaco-react/issues/482
 loader.config({ paths: { vs: '/vs' } })
 
-type Props = {
+export type Props = {
   value?: string | object
+  placeholder?: string
   onChange?: (value: string) => void
   title: JSX.Element
   language: CodeLanguage
@@ -19,6 +21,7 @@ type Props = {
   isJSONStringifyBeauty?: boolean
   height?: number
   isInNode?: boolean
+  onMount?: (editor: any, monaco: any) => void
 }
 
 const languageMap = {
@@ -29,6 +32,7 @@ const languageMap = {
 
 const CodeEditor: FC<Props> = ({
   value = '',
+  placeholder = '',
   onChange = () => { },
   title,
   headerRight,
@@ -37,6 +41,7 @@ const CodeEditor: FC<Props> = ({
   isJSONStringifyBeauty,
   height,
   isInNode,
+  onMount,
 }) => {
   const [isFocus, setIsFocus] = React.useState(false)
 
@@ -47,6 +52,7 @@ const CodeEditor: FC<Props> = ({
   const editorRef = useRef(null)
   const handleEditorDidMount = (editor: any, monaco: any) => {
     editorRef.current = editor
+
     editor.onDidFocusEditorText(() => {
       setIsFocus(true)
     })
@@ -71,6 +77,8 @@ const CodeEditor: FC<Props> = ({
         'editor.background': '#ffffff',
       },
     })
+
+    onMount?.(editor, monaco)
   }
 
   const outPutValue = (() => {
@@ -87,6 +95,7 @@ const CodeEditor: FC<Props> = ({
   return (
     <div>
       <Base
+        className='relative'
         title={title}
         value={outPutValue}
         headerRight={headerRight}
@@ -117,6 +126,7 @@ const CodeEditor: FC<Props> = ({
             }}
             onMount={handleEditorDidMount}
           />
+          {!outPutValue && <div className='pointer-events-none absolute left-[36px] top-0 leading-[18px] text-[13px] font-normal text-gray-300'>{placeholder}</div>}
         </>
       </Base>
     </div>

+ 6 - 2
web/app/components/workflow/nodes/template-transform/panel.tsx

@@ -8,7 +8,7 @@ import VarList from '@/app/components/workflow/nodes/_base/components/variable/v
 import AddButton from '@/app/components/base/button/add-button'
 import Field from '@/app/components/workflow/nodes/_base/components/field'
 import Split from '@/app/components/workflow/nodes/_base/components/split'
-import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
+import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars'
 import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
 import { HelpCircle } from '@/app/components/base/icons/src/vender/line/general'
 import type { NodePanelProps } from '@/app/components/workflow/types'
@@ -28,6 +28,7 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({
     inputs,
     handleVarListChange,
     handleAddVariable,
+    handleAddEmptyVariable,
     handleCodeChange,
     filterVar,
     // single run
@@ -49,7 +50,7 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({
         <Field
           title={t(`${i18nPrefix}.inputVars`)}
           operations={
-            !readOnly ? <AddButton onClick={handleAddVariable} /> : undefined
+            !readOnly ? <AddButton onClick={handleAddEmptyVariable} /> : undefined
           }
         >
           <VarList
@@ -62,6 +63,9 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({
         </Field>
         <Split />
         <CodeEditor
+          nodeId={id}
+          varList={inputs.variables}
+          onAddVar={handleAddVariable}
           isInNode
           readOnly={readOnly}
           language={CodeLanguage.python3}

+ 20 - 6
web/app/components/workflow/nodes/template-transform/use-config.ts

@@ -1,7 +1,7 @@
-import { useCallback, useEffect } from 'react'
+import { useCallback, useEffect, useRef } from 'react'
 import produce from 'immer'
 import useVarList from '../_base/hooks/use-var-list'
-import type { Var } from '../../types'
+import type { Var, Variable } from '../../types'
 import { VarType } from '../../types'
 import { useStore } from '../../store'
 import type { TemplateTransformNodeType } from './types'
@@ -15,12 +15,25 @@ const useConfig = (id: string, payload: TemplateTransformNodeType) => {
   const { nodesReadOnly: readOnly } = useNodesReadOnly()
   const defaultConfig = useStore(s => s.nodesDefaultConfigs)[payload.type]
 
-  const { inputs, setInputs } = useNodeCrud<TemplateTransformNodeType>(id, payload)
-  const { handleVarListChange, handleAddVariable } = useVarList<TemplateTransformNodeType>({
+  const { inputs, setInputs: doSetInputs } = useNodeCrud<TemplateTransformNodeType>(id, payload)
+  const inputsRef = useRef(inputs)
+  const setInputs = useCallback((newPayload: TemplateTransformNodeType) => {
+    doSetInputs(newPayload)
+    inputsRef.current = newPayload
+  }, [doSetInputs])
+
+  const { handleVarListChange, handleAddVariable: handleAddEmptyVariable } = useVarList<TemplateTransformNodeType>({
     inputs,
     setInputs,
   })
 
+  const handleAddVariable = useCallback((payload: Variable) => {
+    const newInputs = produce(inputsRef.current, (draft: any) => {
+      draft.variables.push(payload)
+    })
+    setInputs(newInputs)
+  }, [setInputs])
+
   useEffect(() => {
     if (inputs.template)
       return
@@ -36,11 +49,11 @@ const useConfig = (id: string, payload: TemplateTransformNodeType) => {
   }, [defaultConfig])
 
   const handleCodeChange = useCallback((template: string) => {
-    const newInputs = produce(inputs, (draft: any) => {
+    const newInputs = produce(inputsRef.current, (draft: any) => {
       draft.template = template
     })
     setInputs(newInputs)
-  }, [inputs, setInputs])
+  }, [setInputs])
 
   // single run
   const {
@@ -82,6 +95,7 @@ const useConfig = (id: string, payload: TemplateTransformNodeType) => {
     inputs,
     handleVarListChange,
     handleAddVariable,
+    handleAddEmptyVariable,
     handleCodeChange,
     filterVar,
     // single run

+ 1 - 0
web/i18n/en-US/workflow.ts

@@ -49,6 +49,7 @@ const translation = {
     processData: 'Process Data',
     input: 'Input',
     output: 'Output',
+    jinjaEditorPlaceholder: 'Type \'/\' or \'{\' to insert variable',
     viewOnly: 'View Only',
     showRunHistory: 'Show Run History',
   },

+ 1 - 0
web/i18n/zh-Hans/workflow.ts

@@ -49,6 +49,7 @@ const translation = {
     processData: '数据处理',
     input: '输入',
     output: '输出',
+    jinjaEditorPlaceholder: '输入 “/” 或 “{” 插入变量',
     viewOnly: '只读',
     showRunHistory: '显示运行历史',
   },