Переглянути джерело

feat: synchronize input/output variables in the panel with generated code by the code generator (#10150)

Kota-Yamaguchi 5 місяців тому
батько
коміт
f674de4f5d

+ 3 - 4
web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx

@@ -31,6 +31,7 @@ export type Props = {
   noWrapper?: boolean
   isExpand?: boolean
   showFileList?: boolean
+  onGenerated?: (value: string) => void
   showCodeGenerator?: boolean
 }
 
@@ -64,6 +65,7 @@ const CodeEditor: FC<Props> = ({
   noWrapper,
   isExpand,
   showFileList,
+  onGenerated,
   showCodeGenerator = false,
 }) => {
   const [isFocus, setIsFocus] = React.useState(false)
@@ -151,9 +153,6 @@ const CodeEditor: FC<Props> = ({
 
     return isFocus ? 'focus-theme' : 'blur-theme'
   })()
-  const handleGenerated = (code: string) => {
-    handleEditorChange(code)
-  }
 
   const main = (
     <>
@@ -205,7 +204,7 @@ const CodeEditor: FC<Props> = ({
             isFocus={isFocus && !readOnly}
             minHeight={minHeight}
             isInNode={isInNode}
-            onGenerated={handleGenerated}
+            onGenerated={onGenerated}
             codeLanguages={language}
             fileList={fileList}
             showFileList={showFileList}

+ 326 - 0
web/app/components/workflow/nodes/code/code-parser.spec.ts

@@ -0,0 +1,326 @@
+import { VarType } from '../../types'
+import { extractFunctionParams, extractReturnType } from './code-parser'
+import { CodeLanguage } from './types'
+
+const SAMPLE_CODES = {
+  python3: {
+    noParams: 'def main():',
+    singleParam: 'def main(param1):',
+    multipleParams: `def main(param1, param2, param3):
+      return {"result": param1}`,
+    withTypes: `def main(param1: str, param2: int, param3: List[str]):
+      result = process_data(param1, param2)
+      return {"output": result}`,
+    withDefaults: `def main(param1: str = "default", param2: int = 0):
+      return {"data": param1}`,
+  },
+  javascript: {
+    noParams: 'function main() {',
+    singleParam: 'function main(param1) {',
+    multipleParams: `function main(param1, param2, param3) {
+      return { result: param1 }
+    }`,
+    withComments: `// Main function
+    function main(param1, param2) {
+      // Process data
+      return { output: process(param1, param2) }
+    }`,
+    withSpaces: 'function main(  param1  ,   param2  ) {',
+  },
+}
+
+describe('extractFunctionParams', () => {
+  describe('Python3', () => {
+    test('handles no parameters', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.python3.noParams, CodeLanguage.python3)
+      expect(result).toEqual([])
+    })
+
+    test('extracts single parameter', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.python3.singleParam, CodeLanguage.python3)
+      expect(result).toEqual(['param1'])
+    })
+
+    test('extracts multiple parameters', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.python3.multipleParams, CodeLanguage.python3)
+      expect(result).toEqual(['param1', 'param2', 'param3'])
+    })
+
+    test('handles type hints', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.python3.withTypes, CodeLanguage.python3)
+      expect(result).toEqual(['param1', 'param2', 'param3'])
+    })
+
+    test('handles default values', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.python3.withDefaults, CodeLanguage.python3)
+      expect(result).toEqual(['param1', 'param2'])
+    })
+  })
+
+  // JavaScriptのテストケース
+  describe('JavaScript', () => {
+    test('handles no parameters', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.javascript.noParams, CodeLanguage.javascript)
+      expect(result).toEqual([])
+    })
+
+    test('extracts single parameter', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.javascript.singleParam, CodeLanguage.javascript)
+      expect(result).toEqual(['param1'])
+    })
+
+    test('extracts multiple parameters', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.javascript.multipleParams, CodeLanguage.javascript)
+      expect(result).toEqual(['param1', 'param2', 'param3'])
+    })
+
+    test('handles comments in code', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.javascript.withComments, CodeLanguage.javascript)
+      expect(result).toEqual(['param1', 'param2'])
+    })
+
+    test('handles whitespace', () => {
+      const result = extractFunctionParams(SAMPLE_CODES.javascript.withSpaces, CodeLanguage.javascript)
+      expect(result).toEqual(['param1', 'param2'])
+    })
+  })
+})
+
+const RETURN_TYPE_SAMPLES = {
+  python3: {
+    singleReturn: `
+def main(param1):
+    return {"result": "value"}`,
+
+    multipleReturns: `
+def main(param1, param2):
+    return {"result": "value", "status": "success"}`,
+
+    noReturn: `
+def main():
+    print("Hello")`,
+
+    complexReturn: `
+def main():
+    data = process()
+    return {"result": data, "count": 42, "messages": ["hello"]}`,
+    nestedObject: `
+    def main(name, age, city):
+        return {
+            'personal_info': {
+                'name': name,
+                'age': age,
+                'city': city
+            },
+            'timestamp': int(time.time()),
+            'status': 'active'
+        }`,
+  },
+
+  javascript: {
+    singleReturn: `
+function main(param1) {
+    return { result: "value" }
+}`,
+
+    multipleReturns: `
+function main(param1) {
+    return { result: "value", status: "success" }
+}`,
+
+    withParentheses: `
+function main() {
+    return ({ result: "value", status: "success" })
+}`,
+
+    noReturn: `
+function main() {
+    console.log("Hello")
+}`,
+
+    withQuotes: `
+function main() {
+    return { "result": 'value', 'status': "success" }
+}`,
+    nestedObject: `
+function main(name, age, city) {
+    return {
+        personal_info: {
+            name: name,
+            age: age,
+            city: city
+        },
+        timestamp: Date.now(),
+        status: 'active'
+    }
+}`,
+    withJSDoc: `
+/**
+ * Creates a user profile with personal information and metadata
+ * @param {string} name - The user's name
+ * @param {number} age - The user's age
+ * @param {string} city - The user's city of residence
+ * @returns {Object} An object containing the user profile
+ */
+function main(name, age, city) {
+    return {
+        result: {
+            personal_info: {
+                name: name,
+                age: age,
+                city: city
+            },
+            timestamp: Date.now(),
+            status: 'active'
+        }
+    };
+}`,
+
+  },
+}
+
+describe('extractReturnType', () => {
+  // Python3のテスト
+  describe('Python3', () => {
+    test('extracts single return value', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.singleReturn, CodeLanguage.python3)
+      expect(result).toEqual({
+        result: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+
+    test('extracts multiple return values', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.multipleReturns, CodeLanguage.python3)
+      expect(result).toEqual({
+        result: {
+          type: VarType.string,
+          children: null,
+        },
+        status: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+
+    test('returns empty object when no return statement', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.noReturn, CodeLanguage.python3)
+      expect(result).toEqual({})
+    })
+
+    test('handles complex return statement', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.complexReturn, CodeLanguage.python3)
+      expect(result).toEqual({
+        result: {
+          type: VarType.string,
+          children: null,
+        },
+        count: {
+          type: VarType.string,
+          children: null,
+        },
+        messages: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+    test('handles nested object structure', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.nestedObject, CodeLanguage.python3)
+      expect(result).toEqual({
+        personal_info: {
+          type: VarType.string,
+          children: null,
+        },
+        timestamp: {
+          type: VarType.string,
+          children: null,
+        },
+        status: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+  })
+
+  // JavaScriptのテスト
+  describe('JavaScript', () => {
+    test('extracts single return value', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.singleReturn, CodeLanguage.javascript)
+      expect(result).toEqual({
+        result: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+
+    test('extracts multiple return values', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.multipleReturns, CodeLanguage.javascript)
+      expect(result).toEqual({
+        result: {
+          type: VarType.string,
+          children: null,
+        },
+        status: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+
+    test('handles return with parentheses', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.withParentheses, CodeLanguage.javascript)
+      expect(result).toEqual({
+        result: {
+          type: VarType.string,
+          children: null,
+        },
+        status: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+
+    test('returns empty object when no return statement', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.noReturn, CodeLanguage.javascript)
+      expect(result).toEqual({})
+    })
+
+    test('handles quoted keys', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.withQuotes, CodeLanguage.javascript)
+      expect(result).toEqual({
+        result: {
+          type: VarType.string,
+          children: null,
+        },
+        status: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+    test('handles nested object structure', () => {
+      const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.nestedObject, CodeLanguage.javascript)
+      expect(result).toEqual({
+        personal_info: {
+          type: VarType.string,
+          children: null,
+        },
+        timestamp: {
+          type: VarType.string,
+          children: null,
+        },
+        status: {
+          type: VarType.string,
+          children: null,
+        },
+      })
+    })
+  })
+})

+ 86 - 0
web/app/components/workflow/nodes/code/code-parser.ts

@@ -0,0 +1,86 @@
+import { VarType } from '../../types'
+import type { OutputVar } from './types'
+import { CodeLanguage } from './types'
+
+export const extractFunctionParams = (code: string, language: CodeLanguage) => {
+  if (language === CodeLanguage.json)
+    return []
+
+  const patterns: Record<Exclude<CodeLanguage, CodeLanguage.json>, RegExp> = {
+    [CodeLanguage.python3]: /def\s+main\s*\((.*?)\)/,
+    [CodeLanguage.javascript]: /function\s+main\s*\((.*?)\)/,
+  }
+  const match = code.match(patterns[language])
+  const params: string[] = []
+
+  if (match?.[1]) {
+    params.push(...match[1].split(',')
+      .map(p => p.trim())
+      .filter(Boolean)
+      .map(p => p.split(':')[0].trim()),
+    )
+  }
+
+  return params
+}
+export const extractReturnType = (code: string, language: CodeLanguage): OutputVar => {
+  const codeWithoutComments = code.replace(/\/\*\*[\s\S]*?\*\//, '')
+  console.log(codeWithoutComments)
+
+  const returnIndex = codeWithoutComments.indexOf('return')
+  if (returnIndex === -1)
+    return {}
+
+  // returnから始まる部分文字列を取得
+  const codeAfterReturn = codeWithoutComments.slice(returnIndex)
+
+  let bracketCount = 0
+  let startIndex = codeAfterReturn.indexOf('{')
+
+  if (language === CodeLanguage.javascript && startIndex === -1) {
+    const parenStart = codeAfterReturn.indexOf('(')
+    if (parenStart !== -1)
+      startIndex = codeAfterReturn.indexOf('{', parenStart)
+  }
+
+  if (startIndex === -1)
+    return {}
+
+  let endIndex = -1
+
+  for (let i = startIndex; i < codeAfterReturn.length; i++) {
+    if (codeAfterReturn[i] === '{')
+      bracketCount++
+    if (codeAfterReturn[i] === '}') {
+      bracketCount--
+      if (bracketCount === 0) {
+        endIndex = i + 1
+        break
+      }
+    }
+  }
+
+  if (endIndex === -1)
+    return {}
+
+  const returnContent = codeAfterReturn.slice(startIndex + 1, endIndex - 1)
+  console.log(returnContent)
+
+  const result: OutputVar = {}
+
+  const keyRegex = /['"]?(\w+)['"]?\s*:(?![^{]*})/g
+  const matches = returnContent.matchAll(keyRegex)
+
+  for (const match of matches) {
+    console.log(`Found key: "${match[1]}" from match: "${match[0]}"`)
+    const key = match[1]
+    result[key] = {
+      type: VarType.string,
+      children: null,
+    }
+  }
+
+  console.log(result)
+
+  return result
+}

+ 16 - 2
web/app/components/workflow/nodes/code/panel.tsx

@@ -5,6 +5,7 @@ import RemoveEffectVarConfirm from '../_base/components/remove-effect-var-confir
 import useConfig from './use-config'
 import type { CodeNodeType } from './types'
 import { CodeLanguage } from './types'
+import { extractFunctionParams, extractReturnType } from './code-parser'
 import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
 import OutputVarList from '@/app/components/workflow/nodes/_base/components/variable/output-var-list'
 import AddButton from '@/app/components/base/button/add-button'
@@ -12,10 +13,9 @@ 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 TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
-import type { NodePanelProps } from '@/app/components/workflow/types'
+import { type NodePanelProps } from '@/app/components/workflow/types'
 import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
 import ResultPanel from '@/app/components/workflow/run/result-panel'
-
 const i18nPrefix = 'workflow.nodes.code'
 
 const codeLanguages = [
@@ -38,6 +38,7 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({
     readOnly,
     inputs,
     outputKeyOrders,
+    handleCodeAndVarsChange,
     handleVarListChange,
     handleAddVariable,
     handleRemoveVariable,
@@ -61,6 +62,18 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({
     setInputVarValues,
   } = useConfig(id, data)
 
+  const handleGeneratedCode = (value: string) => {
+    const params = extractFunctionParams(value, inputs.code_language)
+    const codeNewInput = params.map((p) => {
+      return {
+        variable: p,
+        value_selector: [],
+      }
+    })
+    const returnTypes = extractReturnType(value, inputs.code_language)
+    handleCodeAndVarsChange(value, codeNewInput, returnTypes)
+  }
+
   return (
     <div className='mt-2'>
       <div className='px-4 pb-4 space-y-4'>
@@ -92,6 +105,7 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({
           language={inputs.code_language}
           value={inputs.code}
           onChange={handleCodeChange}
+          onGenerated={handleGeneratedCode}
           showCodeGenerator={true}
         />
       </div>

+ 11 - 2
web/app/components/workflow/nodes/code/use-config.ts

@@ -3,7 +3,7 @@ import produce from 'immer'
 import useVarList from '../_base/hooks/use-var-list'
 import useOutputVarList from '../_base/hooks/use-output-var-list'
 import { BlockEnum, VarType } from '../../types'
-import type { Var } from '../../types'
+import type { Var, Variable } from '../../types'
 import { useStore } from '../../store'
 import type { CodeNodeType, OutputVar } from './types'
 import { CodeLanguage } from './types'
@@ -136,7 +136,15 @@ const useConfig = (id: string, payload: CodeNodeType) => {
   const setInputVarValues = useCallback((newPayload: Record<string, any>) => {
     setRunInputData(newPayload)
   }, [setRunInputData])
-
+  const handleCodeAndVarsChange = useCallback((code: string, inputVariables: Variable[], outputVariables: OutputVar) => {
+    const newInputs = produce(inputs, (draft) => {
+      draft.code = code
+      draft.variables = inputVariables
+      draft.outputs = outputVariables
+    })
+    setInputs(newInputs)
+    syncOutputKeyOrders(outputVariables)
+  }, [inputs, setInputs, syncOutputKeyOrders])
   return {
     readOnly,
     inputs,
@@ -163,6 +171,7 @@ const useConfig = (id: string, payload: CodeNodeType) => {
     inputVarValues,
     setInputVarValues,
     runResult,
+    handleCodeAndVarsChange,
   }
 }