浏览代码

feat: llm support jinja fe (#4260)

Joel 11 月之前
父节点
当前提交
01555463d2

文件差异内容过多而无法显示
+ 13 - 0
web/app/components/base/icons/assets/vender/workflow/jinja.svg


文件差异内容过多而无法显示
+ 98 - 0
web/app/components/base/icons/src/vender/workflow/Jinja.json


+ 16 - 0
web/app/components/base/icons/src/vender/workflow/Jinja.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Jinja.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Jinja'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/workflow/index.ts

@@ -4,6 +4,7 @@ export { default as End } from './End'
 export { default as Home } from './Home'
 export { default as Http } from './Http'
 export { default as IfElse } from './IfElse'
+export { default as Jinja } from './Jinja'
 export { default as KnowledgeRetrieval } from './KnowledgeRetrieval'
 export { default as Llm } from './Llm'
 export { default as QuestionClassifier } from './QuestionClassifier'

+ 4 - 1
web/app/components/base/switch/index.tsx

@@ -5,7 +5,7 @@ import { Switch as OriginalSwitch } from '@headlessui/react'
 
 type SwitchProps = {
   onChange: (value: boolean) => void
-  size?: 'md' | 'lg' | 'l'
+  size?: 'sm' | 'md' | 'lg' | 'l'
   defaultValue?: boolean
   disabled?: boolean
 }
@@ -19,18 +19,21 @@ const Switch = ({ onChange, size = 'lg', defaultValue = false, disabled = false
     lg: 'h-6 w-11',
     l: 'h-5 w-9',
     md: 'h-4 w-7',
+    sm: 'h-3 w-5',
   }
 
   const circleStyle = {
     lg: 'h-5 w-5',
     l: 'h-4 w-4',
     md: 'h-3 w-3',
+    sm: 'h-2 w-2',
   }
 
   const translateLeft = {
     lg: 'translate-x-5',
     l: 'translate-x-4',
     md: 'translate-x-3',
+    sm: 'translate-x-2',
   }
   return (
     <OriginalSwitch

+ 50 - 7
web/app/components/base/tooltip-plus/index.tsx

@@ -1,7 +1,8 @@
 'use client'
 import type { FC } from 'react'
-import React, { useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import cn from 'classnames'
+import { useBoolean } from 'ahooks'
 import type { OffsetOptions, Placement } from '@floating-ui/react'
 import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
 export type TooltipProps = {
@@ -28,6 +29,39 @@ const Tooltip: FC<TooltipProps> = ({
   offset,
 }) => {
   const [open, setOpen] = useState(false)
+  const [isHoverPopup, {
+    setTrue: setHoverPopup,
+    setFalse: setNotHoverPopup,
+  }] = useBoolean(false)
+
+  const isHoverPopupRef = useRef(isHoverPopup)
+  useEffect(() => {
+    isHoverPopupRef.current = isHoverPopup
+  }, [isHoverPopup])
+
+  const [isHoverTrigger, {
+    setTrue: setHoverTrigger,
+    setFalse: setNotHoverTrigger,
+  }] = useBoolean(false)
+
+  const isHoverTriggerRef = useRef(isHoverTrigger)
+  useEffect(() => {
+    isHoverTriggerRef.current = isHoverTrigger
+  }, [isHoverTrigger])
+
+  const handleLeave = (isTrigger: boolean) => {
+    if (isTrigger)
+      setNotHoverTrigger()
+
+    else
+      setNotHoverPopup()
+
+    // give time to move to the popup
+    setTimeout(() => {
+      if (!isHoverPopupRef.current && !isHoverTriggerRef.current)
+        setOpen(false)
+    }, 500)
+  }
 
   return (
     <PortalToFollowElem
@@ -38,18 +72,27 @@ const Tooltip: FC<TooltipProps> = ({
     >
       <PortalToFollowElemTrigger
         onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
-        onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)}
-        onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)}
+        onMouseEnter={() => {
+          if (triggerMethod === 'hover') {
+            setHoverTrigger()
+            setOpen(true)
+          }
+        }}
+        onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)}
       >
         {children}
       </PortalToFollowElemTrigger>
       <PortalToFollowElemContent
         className="z-[9999]"
       >
-        <div className={cn(
-          'relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg',
-          popupClassName,
-        )}>
+        <div
+          className={cn(
+            'relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg',
+            popupClassName,
+          )}
+          onMouseEnter={() => triggerMethod === 'hover' && setHoverPopup()}
+          onMouseLeave={() => triggerMethod === 'hover' && handleLeave(false)}
+        >
           {popupContent}
           {!hideArrow && arrow}
         </div>

+ 9 - 13
web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx

@@ -3,33 +3,28 @@ import type { FC } from 'react'
 import React, { useEffect, useRef, useState } from 'react'
 import { useBoolean } from 'ahooks'
 import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
 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'
+import type { NodeOutPutVar, Variable } from '@/app/components/workflow/types'
 
 const TO_WINDOW_OFFSET = 8
 
 type Props = {
-  nodeId: string
+  availableVars: NodeOutPutVar[]
   varList: Variable[]
-  onAddVar: (payload: Variable) => void
+  onAddVar?: (payload: Variable) => void
 } & EditorProps
 
 const CodeEditor: FC<Props> = ({
-  nodeId,
+  availableVars,
   varList,
   onAddVar,
   ...editorProps
 }) => {
   const { t } = useTranslation()
 
-  const { availableVars } = useAvailableVarList(nodeId, {
-    onlyLeafNodeVar: false,
-    filterVar: () => true,
-  })
-
   const isLeftBraceRef = useRef(false)
 
   const editorRef = useRef(null)
@@ -76,7 +71,8 @@ const CodeEditor: FC<Props> = ({
       if (popupPosition.y + height > window.innerHeight - TO_WINDOW_OFFSET)
         newPopupPosition.y = window.innerHeight - height - TO_WINDOW_OFFSET
 
-      setPopupPosition(newPopupPosition)
+      if (newPopupPosition.x !== popupPosition.x || newPopupPosition.y !== popupPosition.y)
+        setPopupPosition(newPopupPosition)
     }
   }, [isShowVarPicker, popupPosition])
 
@@ -124,7 +120,7 @@ const CodeEditor: FC<Props> = ({
         value_selector: varValue,
       }
 
-      onAddVar(newVar)
+      onAddVar?.(newVar)
     }
     const editor: any = editorRef.current
     const monaco: any = monacoRef.current
@@ -143,7 +139,7 @@ const CodeEditor: FC<Props> = ({
   }
 
   return (
-    <div>
+    <div className={cn(editorProps.isExpand && 'h-full')}>
       <Editor
         {...editorProps}
         onMount={onEditorMounted}

+ 100 - 40
web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx

@@ -1,20 +1,23 @@
 'use client'
 import type { FC } from 'react'
 import Editor, { loader } from '@monaco-editor/react'
-
-import React, { useRef } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
+import cn from 'classnames'
 import Base from '../base'
 import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
+
 import './style.css'
 
 // load file from local instead of cdn https://github.com/suren-atoyan/monaco-react/issues/482
 loader.config({ paths: { vs: '/vs' } })
 
+const CODE_EDITOR_LINE_HEIGHT = 18
+
 export type Props = {
   value?: string | object
   placeholder?: string
   onChange?: (value: string) => void
-  title: JSX.Element
+  title?: JSX.Element
   language: CodeLanguage
   headerRight?: JSX.Element
   readOnly?: boolean
@@ -22,6 +25,8 @@ export type Props = {
   height?: number
   isInNode?: boolean
   onMount?: (editor: any, monaco: any) => void
+  noWrapper?: boolean
+  isExpand?: boolean
 }
 
 const languageMap = {
@@ -30,11 +35,20 @@ const languageMap = {
   [CodeLanguage.json]: 'json',
 }
 
+const DEFAULT_THEME = {
+  base: 'vs',
+  inherit: true,
+  rules: [],
+  colors: {
+    'editor.background': '#F2F4F7', // #00000000 transparent. But it will has a blue border
+  },
+}
+
 const CodeEditor: FC<Props> = ({
   value = '',
   placeholder = '',
   onChange = () => { },
-  title,
+  title = '',
   headerRight,
   language,
   readOnly,
@@ -42,16 +56,37 @@ const CodeEditor: FC<Props> = ({
   height,
   isInNode,
   onMount,
+  noWrapper,
+  isExpand,
 }) => {
   const [isFocus, setIsFocus] = React.useState(false)
+  const [isMounted, setIsMounted] = React.useState(false)
+  const minHeight = height || 200
+  const [editorContentHeight, setEditorContentHeight] = useState(56)
+
+  const valueRef = useRef(value)
+  useEffect(() => {
+    valueRef.current = value
+  }, [value])
+
+  const editorRef = useRef<any>(null)
+  const resizeEditorToContent = () => {
+    if (editorRef.current) {
+      const contentHeight = editorRef.current.getContentHeight() // Math.max(, minHeight)
+      setEditorContentHeight(contentHeight)
+    }
+  }
 
   const handleEditorChange = (value: string | undefined) => {
     onChange(value || '')
+    setTimeout(() => {
+      resizeEditorToContent()
+    }, 10)
   }
 
-  const editorRef = useRef(null)
   const handleEditorDidMount = (editor: any, monaco: any) => {
     editorRef.current = editor
+    resizeEditorToContent()
 
     editor.onDidFocusEditorText(() => {
       setIsFocus(true)
@@ -60,6 +95,8 @@ const CodeEditor: FC<Props> = ({
       setIsFocus(false)
     })
 
+    monaco.editor.defineTheme('default-theme', DEFAULT_THEME)
+
     monaco.editor.defineTheme('blur-theme', {
       base: 'vs',
       inherit: true,
@@ -78,7 +115,10 @@ const CodeEditor: FC<Props> = ({
       },
     })
 
+    monaco.editor.setTheme('default-theme') // Fix: sometimes not load the default theme
+
     onMount?.(editor, monaco)
+    setIsMounted(true)
   }
 
   const outPutValue = (() => {
@@ -92,43 +132,63 @@ const CodeEditor: FC<Props> = ({
     }
   })()
 
-  return (
-    <div>
-      <Base
-        className='relative'
-        title={title}
+  const theme = (() => {
+    if (noWrapper)
+      return 'default-theme'
+
+    return isFocus ? 'focus-theme' : 'blur-theme'
+  })()
+
+  const main = (
+    <>
+      {/* https://www.npmjs.com/package/@monaco-editor/react */}
+      <Editor
+        // className='min-h-[100%]' // h-full
+        // language={language === CodeLanguage.javascript ? 'javascript' : 'python'}
+        language={languageMap[language] || 'javascript'}
+        theme={isMounted ? theme : 'default-theme'} // sometimes not load the default theme
         value={outPutValue}
-        headerRight={headerRight}
-        isFocus={isFocus && !readOnly}
-        minHeight={height || 200}
-        isInNode={isInNode}
-      >
-        <>
-          {/* https://www.npmjs.com/package/@monaco-editor/react */}
-          <Editor
-            className='h-full'
-            // language={language === CodeLanguage.javascript ? 'javascript' : 'python'}
-            language={languageMap[language] || 'javascript'}
-            theme={isFocus ? 'focus-theme' : 'blur-theme'}
+        onChange={handleEditorChange}
+        // https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IEditorOptions.html
+        options={{
+          readOnly,
+          domReadOnly: true,
+          quickSuggestions: false,
+          minimap: { enabled: false },
+          lineNumbersMinChars: 1, // would change line num width
+          wordWrap: 'on', // auto line wrap
+          // lineNumbers: (num) => {
+          //   return <div>{num}</div>
+          // }
+        }}
+        onMount={handleEditorDidMount}
+      />
+      {!outPutValue && <div className='pointer-events-none absolute left-[36px] top-0 leading-[18px] text-[13px] font-normal text-gray-300'>{placeholder}</div>}
+    </>
+  )
+
+  return (
+    <div className={cn(isExpand && 'h-full')}>
+      {noWrapper
+        ? <div className='relative no-wrapper' style={{
+          height: isExpand ? '100%' : (editorContentHeight) / 2 + CODE_EDITOR_LINE_HEIGHT, // In IDE, the last line can always be in lop line. So there is some blank space in the bottom.
+          minHeight: CODE_EDITOR_LINE_HEIGHT,
+        }}>
+          {main}
+        </div>
+        : (
+          <Base
+            className='relative'
+            title={title}
             value={outPutValue}
-            onChange={handleEditorChange}
-            // https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IEditorOptions.html
-            options={{
-              readOnly,
-              domReadOnly: true,
-              quickSuggestions: false,
-              minimap: { enabled: false },
-              lineNumbersMinChars: 1, // would change line num width
-              wordWrap: 'on', // auto line wrap
-              // lineNumbers: (num) => {
-              //   return <div>{num}</div>
-              // }
-            }}
-            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>
+            headerRight={headerRight}
+            isFocus={isFocus && !readOnly}
+            minHeight={minHeight}
+            isInNode={isInNode}
+          >
+            {main}
+          </Base>
+        )}
     </div>
   )
 }

+ 4 - 0
web/app/components/workflow/nodes/_base/components/editor/code-editor/style.css

@@ -2,6 +2,10 @@
   padding-left: 10px;
 }
 
+.no-wrapper .margin-view-overlays {
+  padding-left: 0;
+}
+
 /* hide readonly tooltip */
 .monaco-editor-overlaymessage {
   display: none !important;

+ 114 - 69
web/app/components/workflow/nodes/_base/components/prompt/editor.tsx

@@ -1,16 +1,19 @@
 'use client'
 import type { FC } from 'react'
-import React, { useCallback, useEffect, useRef, useState } from 'react'
+import React, { useCallback, useRef } from 'react'
 import cn from 'classnames'
 import copy from 'copy-to-clipboard'
 import { useTranslation } from 'react-i18next'
 import { useBoolean } from 'ahooks'
-import {
-  BlockEnum,
-  type Node,
-  type NodeOutPutVar,
+import { BlockEnum, EditionType } from '../../../../types'
+import type {
+  Node,
+  NodeOutPutVar,
+  Variable,
 } from '../../../../types'
+
 import Wrap from '../editor/wrap'
+import { CodeLanguage } from '../../../code/types'
 import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/toggle-expand-btn'
 import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend'
 import PromptEditor from '@/app/components/base/prompt-editor'
@@ -21,6 +24,10 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
 import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
 import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
 import TooltipPlus from '@/app/components/base/tooltip-plus'
+import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars'
+import Switch from '@/app/components/base/switch'
+import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
+
 type Props = {
   className?: string
   headerClassName?: string
@@ -42,6 +49,12 @@ type Props = {
   }
   nodesOutputVars?: NodeOutPutVar[]
   availableNodes?: Node[]
+  // for jinja
+  isSupportJinja?: boolean
+  editionType?: EditionType
+  onEditionTypeChange?: (editionType: EditionType) => void
+  varList?: Variable[]
+  handleAddVariable?: (payload: any) => void
 }
 
 const Editor: FC<Props> = ({
@@ -61,6 +74,11 @@ const Editor: FC<Props> = ({
   hasSetBlockStatus,
   nodesOutputVars,
   availableNodes = [],
+  isSupportJinja,
+  editionType,
+  onEditionTypeChange,
+  varList = [],
+  handleAddVariable,
 }) => {
   const { t } = useTranslation()
   const { eventEmitter } = useEventEmitterContextContext()
@@ -85,20 +103,6 @@ const Editor: FC<Props> = ({
     setTrue: setFocus,
     setFalse: setBlur,
   }] = useBoolean(false)
-  const hideTooltipRunId = useRef(0)
-
-  const [isShowInsertToolTip, setIsShowInsertTooltip] = useState(false)
-  useEffect(() => {
-    if (isFocus) {
-      clearTimeout(hideTooltipRunId.current)
-      setIsShowInsertTooltip(true)
-    }
-    else {
-      hideTooltipRunId.current = setTimeout(() => {
-        setIsShowInsertTooltip(false)
-      }, 100) as any
-    }
-  }, [isFocus])
 
   const handleInsertVariable = () => {
     setFocus()
@@ -116,6 +120,29 @@ const Editor: FC<Props> = ({
               <div className='w-px h-3 ml-2 mr-2 bg-gray-200'></div>
               {/* Operations */}
               <div className='flex items-center space-x-2'>
+                {isSupportJinja && (
+                  <TooltipPlus
+                    popupContent={
+                      <div>
+                        <div>{t('workflow.common.enableJinja')}</div>
+                        <a className='text-[#155EEF]' target='_blank' href='https://jinja.palletsprojects.com/en/2.10.x/'>{t('workflow.common.learnMore')}</a>
+                      </div>
+                    }
+                    hideArrow
+                  >
+                    <div className={cn(editionType === EditionType.jinja2 && 'border-black/5 bg-white', 'mb-1 flex h-[22px] items-center px-1.5 rounded-[5px] border border-transparent hover:border-black/5 space-x-0.5')}>
+                      <Jinja className='w-6 h-3 text-gray-300' />
+                      <Switch
+                        size='sm'
+                        defaultValue={editionType === EditionType.jinja2}
+                        onChange={(checked) => {
+                          onEditionTypeChange?.(checked ? EditionType.jinja2 : EditionType.basic)
+                        }}
+                      />
+                    </div>
+                  </TooltipPlus>
+
+                )}
                 {!readOnly && (
                   <TooltipPlus
                     popupContent={`${t('workflow.common.insertVarTip')}`}
@@ -142,57 +169,75 @@ const Editor: FC<Props> = ({
 
           {/* Min: 80 Max: 560. Header: 24 */}
           <div className={cn('pb-2', isExpand && 'flex flex-col grow')}>
-            <div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative px-3 min-h-[56px]  overflow-y-auto')}>
-              <PromptEditor
-                instanceId={instanceId}
-                compact
-                className='min-h-[56px]'
-                style={isExpand ? { height: editorExpandHeight - 5 } : {}}
-                value={value}
-                contextBlock={{
-                  show: justVar ? false : isShowContext,
-                  selectable: !hasSetBlockStatus?.context,
-                  canNotAddContext: true,
-                }}
-                historyBlock={{
-                  show: justVar ? false : isShowHistory,
-                  selectable: !hasSetBlockStatus?.history,
-                  history: {
-                    user: 'Human',
-                    assistant: 'Assistant',
-                  },
-                }}
-                queryBlock={{
-                  show: false, // use [sys.query] instead of query block
-                  selectable: false,
-                }}
-                workflowVariableBlock={{
-                  show: true,
-                  variables: nodesOutputVars || [],
-                  workflowNodesMap: availableNodes.reduce((acc, node) => {
-                    acc[node.id] = {
-                      title: node.data.title,
-                      type: node.data.type,
-                    }
-                    if (node.data.type === BlockEnum.Start) {
-                      acc.sys = {
-                        title: t('workflow.blocks.start'),
-                        type: BlockEnum.Start,
-                      }
-                    }
-                    return acc
-                  }, {} as any),
-                }}
-                onChange={onChange}
-                onBlur={setBlur}
-                onFocus={setFocus}
-                editable={!readOnly}
-              />
-              {/* to patch Editor not support dynamic change editable status */}
-              {readOnly && <div className='absolute inset-0 z-10'></div>}
-            </div>
+            {!(isSupportJinja && editionType === EditionType.jinja2)
+              ? (
+                <div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative px-3 min-h-[56px]  overflow-y-auto')}>
+                  <PromptEditor
+                    instanceId={instanceId}
+                    compact
+                    className='min-h-[56px]'
+                    style={isExpand ? { height: editorExpandHeight - 5 } : {}}
+                    value={value}
+                    contextBlock={{
+                      show: justVar ? false : isShowContext,
+                      selectable: !hasSetBlockStatus?.context,
+                      canNotAddContext: true,
+                    }}
+                    historyBlock={{
+                      show: justVar ? false : isShowHistory,
+                      selectable: !hasSetBlockStatus?.history,
+                      history: {
+                        user: 'Human',
+                        assistant: 'Assistant',
+                      },
+                    }}
+                    queryBlock={{
+                      show: false, // use [sys.query] instead of query block
+                      selectable: false,
+                    }}
+                    workflowVariableBlock={{
+                      show: true,
+                      variables: nodesOutputVars || [],
+                      workflowNodesMap: availableNodes.reduce((acc, node) => {
+                        acc[node.id] = {
+                          title: node.data.title,
+                          type: node.data.type,
+                        }
+                        if (node.data.type === BlockEnum.Start) {
+                          acc.sys = {
+                            title: t('workflow.blocks.start'),
+                            type: BlockEnum.Start,
+                          }
+                        }
+                        return acc
+                      }, {} as any),
+                    }}
+                    onChange={onChange}
+                    onBlur={setBlur}
+                    onFocus={setFocus}
+                    editable={!readOnly}
+                  />
+                  {/* to patch Editor not support dynamic change editable status */}
+                  {readOnly && <div className='absolute inset-0 z-10'></div>}
+                </div>
+              )
+              : (
+                <div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative px-3 min-h-[56px]  overflow-y-auto')}>
+                  <CodeEditor
+                    availableVars={nodesOutputVars || []}
+                    varList={varList}
+                    onAddVar={handleAddVariable}
+                    isInNode
+                    readOnly={readOnly}
+                    language={CodeLanguage.python3}
+                    value={value}
+                    onChange={onChange}
+                    noWrapper
+                    isExpand={isExpand}
+                  />
+                </div>
+              )}
           </div>
-
         </div>
       </div>
     </Wrap>

+ 15 - 2
web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx

@@ -3,7 +3,8 @@ import type { FC } from 'react'
 import React, { useEffect, useState } from 'react'
 import { uniqueId } from 'lodash-es'
 import { useTranslation } from 'react-i18next'
-import type { PromptItem } from '../../../types'
+import type { PromptItem, Variable } from '../../../types'
+import { EditionType } from '../../../types'
 import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
 import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
 import TooltipPlus from '@/app/components/base/tooltip-plus'
@@ -24,6 +25,7 @@ type Props = {
   payload: PromptItem
   handleChatModeMessageRoleChange: (role: PromptRole) => void
   onPromptChange: (p: string) => void
+  onEditionTypeChange: (editionType: EditionType) => void
   onRemove: () => void
   isShowContext: boolean
   hasSetBlockStatus: {
@@ -33,6 +35,8 @@ type Props = {
   }
   availableVars: any
   availableNodes: any
+  varList: Variable[]
+  handleAddVariable: (payload: any) => void
 }
 
 const roleOptions = [
@@ -64,17 +68,21 @@ const ConfigPromptItem: FC<Props> = ({
   isChatApp,
   payload,
   onPromptChange,
+  onEditionTypeChange,
   onRemove,
   isShowContext,
   hasSetBlockStatus,
   availableVars,
   availableNodes,
+  varList,
+  handleAddVariable,
 }) => {
   const { t } = useTranslation()
   const [instanceId, setInstanceId] = useState(uniqueId())
   useEffect(() => {
     setInstanceId(`${id}-${uniqueId()}`)
   }, [id])
+
   return (
     <Editor
       className={className}
@@ -107,7 +115,7 @@ const ConfigPromptItem: FC<Props> = ({
           </TooltipPlus>
         </div>
       }
-      value={payload.text}
+      value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text}
       onChange={onPromptChange}
       readOnly={readOnly}
       showRemove={canRemove}
@@ -118,6 +126,11 @@ const ConfigPromptItem: FC<Props> = ({
       hasSetBlockStatus={hasSetBlockStatus}
       nodesOutputVars={availableVars}
       availableNodes={availableNodes}
+      isSupportJinja
+      editionType={payload.edition_type}
+      onEditionTypeChange={onEditionTypeChange}
+      varList={varList}
+      handleAddVariable={handleAddVariable}
     />
   )
 }

+ 34 - 6
web/app/components/workflow/nodes/llm/components/config-prompt.tsx

@@ -6,8 +6,8 @@ import produce from 'immer'
 import { ReactSortable } from 'react-sortablejs'
 import { v4 as uuid4 } from 'uuid'
 import cn from 'classnames'
-import type { PromptItem, ValueSelector, Var } from '../../../types'
-import { PromptRole } from '../../../types'
+import type { PromptItem, ValueSelector, Var, Variable } from '../../../types'
+import { EditionType, PromptRole } from '../../../types'
 import useAvailableVarList from '../../_base/hooks/use-available-var-list'
 import ConfigPromptItem from './config-prompt-item'
 import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
@@ -30,6 +30,8 @@ type Props = {
     history: boolean
     query: boolean
   }
+  varList?: Variable[]
+  handleAddVariable: (payload: any) => void
 }
 
 const ConfigPrompt: FC<Props> = ({
@@ -42,10 +44,12 @@ const ConfigPrompt: FC<Props> = ({
   onChange,
   isShowContext,
   hasSetBlockStatus,
+  varList = [],
+  handleAddVariable,
 }) => {
   const { t } = useTranslation()
   const payloadWithIds = (isChatModel && Array.isArray(payload))
-    ? payload.map((item, i) => {
+    ? payload.map((item) => {
       const id = uuid4()
       return {
         id: item.id || id,
@@ -67,7 +71,16 @@ const ConfigPrompt: FC<Props> = ({
   const handleChatModePromptChange = useCallback((index: number) => {
     return (prompt: string) => {
       const newPrompt = produce(payload as PromptItem[], (draft) => {
-        draft[index].text = prompt
+        draft[index][draft[index].edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt
+      })
+      onChange(newPrompt)
+    }
+  }, [onChange, payload])
+
+  const handleChatModeEditionTypeChange = useCallback((index: number) => {
+    return (editionType: EditionType) => {
+      const newPrompt = produce(payload as PromptItem[], (draft) => {
+        draft[index].edition_type = editionType
       })
       onChange(newPrompt)
     }
@@ -106,7 +119,14 @@ const ConfigPrompt: FC<Props> = ({
 
   const handleCompletionPromptChange = useCallback((prompt: string) => {
     const newPrompt = produce(payload as PromptItem, (draft) => {
-      draft.text = prompt
+      draft[draft.edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt
+    })
+    onChange(newPrompt)
+  }, [onChange, payload])
+
+  const handleCompletionEditionTypeChange = useCallback((editionType: EditionType) => {
+    const newPrompt = produce(payload as PromptItem, (draft) => {
+      draft.edition_type = editionType
     })
     onChange(newPrompt)
   }, [onChange, payload])
@@ -161,11 +181,14 @@ const ConfigPrompt: FC<Props> = ({
                           isChatApp={isChatApp}
                           payload={item}
                           onPromptChange={handleChatModePromptChange(index)}
+                          onEditionTypeChange={handleChatModeEditionTypeChange(index)}
                           onRemove={handleRemove(index)}
                           isShowContext={isShowContext}
                           hasSetBlockStatus={hasSetBlockStatus}
                           availableVars={availableVars}
                           availableNodes={availableNodes}
+                          varList={varList}
+                          handleAddVariable={handleAddVariable}
                         />
                       </div>
 
@@ -187,7 +210,7 @@ const ConfigPrompt: FC<Props> = ({
             <Editor
               instanceId={`${nodeId}-chat-workflow-llm-prompt-editor`}
               title={<span className='capitalize'>{t(`${i18nPrefix}.prompt`)}</span>}
-              value={(payload as PromptItem).text}
+              value={(payload as PromptItem).edition_type === EditionType.basic ? (payload as PromptItem).text : ((payload as PromptItem).jinja2_text || '')}
               onChange={handleCompletionPromptChange}
               readOnly={readOnly}
               isChatModel={isChatModel}
@@ -196,6 +219,11 @@ const ConfigPrompt: FC<Props> = ({
               hasSetBlockStatus={hasSetBlockStatus}
               nodesOutputVars={availableVars}
               availableNodes={availableNodes}
+              isSupportJinja
+              editionType={(payload as PromptItem).edition_type}
+              varList={varList}
+              onEditionTypeChange={handleCompletionEditionTypeChange}
+              handleAddVariable={handleAddVariable}
             />
           </div>
         )}

+ 19 - 4
web/app/components/workflow/nodes/llm/default.ts

@@ -1,7 +1,6 @@
-import { BlockEnum } from '../../types'
-import { type NodeDefault, PromptRole } from '../../types'
+import { BlockEnum, EditionType } from '../../types'
+import { type NodeDefault, type PromptItem, PromptRole } from '../../types'
 import type { LLMNodeType } from './types'
-import type { PromptItem } from '@/models/debug'
 import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/constants'
 
 const i18nPrefix = 'workflow.errorMsg'
@@ -16,7 +15,6 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
         temperature: 0.7,
       },
     },
-    variables: [],
     prompt_template: [{
       role: PromptRole.system,
       text: '',
@@ -57,6 +55,23 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
       if (isChatModel && !!payload.memory.query_prompt_template && !payload.memory.query_prompt_template.includes('{{#sys.query#}}'))
         errorMessages = t('workflow.nodes.llm.sysQueryInUser')
     }
+
+    if (!errorMessages) {
+      const isChatModel = payload.model.mode === 'chat'
+      const isShowVars = (() => {
+        if (isChatModel)
+          return (payload.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2)
+        return (payload.prompt_template as PromptItem).edition_type === EditionType.jinja2
+      })()
+      if (isShowVars && payload.prompt_config?.jinja2_variables) {
+        payload.prompt_config?.jinja2_variables.forEach((i) => {
+          if (!errorMessages && !i.variable)
+            errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variable`) })
+          if (!errorMessages && !i.value_selector.length)
+            errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
+        })
+      }
+    }
     return {
       isValid: !errorMessages,
       errorMessage: errorMessages,

+ 27 - 0
web/app/components/workflow/nodes/llm/panel.tsx

@@ -7,6 +7,8 @@ import useConfig from './use-config'
 import ResolutionPicker from './components/resolution-picker'
 import type { LLMNodeType } from './types'
 import ConfigPrompt from './components/config-prompt'
+import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
+import AddButton2 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 ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@@ -44,7 +46,12 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
     filterVar,
     availableVars,
     availableNodes,
+    isShowVars,
     handlePromptChange,
+    handleAddEmptyVariable,
+    handleAddVariable,
+    handleVarListChange,
+    handleVarNameChange,
     handleSyeQueryChange,
     handleMemoryChange,
     handleVisionResolutionEnabledChange,
@@ -169,9 +176,29 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
             payload={inputs.prompt_template}
             onChange={handlePromptChange}
             hasSetBlockStatus={hasSetBlockStatus}
+            varList={inputs.prompt_config?.jinja2_variables || []}
+            handleAddVariable={handleAddVariable}
           />
         )}
 
+        {isShowVars && (
+          <Field
+            title={t('workflow.nodes.templateTransform.inputVars')}
+            operations={
+              !readOnly ? <AddButton2 onClick={handleAddEmptyVariable} /> : undefined
+            }
+          >
+            <VarList
+              nodeId={id}
+              readonly={readOnly}
+              list={inputs.prompt_config?.jinja2_variables || []}
+              onChange={handleVarListChange}
+              onVarNameChange={handleVarNameChange}
+              filterVar={filterVar}
+            />
+          </Field>
+        )}
+
         {/* Memory put place examples. */}
         {isChatMode && isChatModel && !!inputs.memory && (
           <div className='mt-4'>

+ 3 - 1
web/app/components/workflow/nodes/llm/types.ts

@@ -3,8 +3,10 @@ import type { CommonNodeType, Memory, ModelConfig, PromptItem, ValueSelector, Va
 
 export type LLMNodeType = CommonNodeType & {
   model: ModelConfig
-  variables: Variable[]
   prompt_template: PromptItem[] | PromptItem
+  prompt_config?: {
+    jinja2_variables?: Variable[]
+  }
   memory?: Memory
   context: {
     enabled: boolean

+ 94 - 33
web/app/components/workflow/nodes/llm/use-config.ts

@@ -1,8 +1,7 @@
 import { useCallback, useEffect, useRef, useState } from 'react'
 import produce from 'immer'
-import useVarList from '../_base/hooks/use-var-list'
-import { VarType } from '../../types'
-import type { Memory, ValueSelector, Var } from '../../types'
+import { EditionType, VarType } from '../../types'
+import type { Memory, PromptItem, ValueSelector, Var, Variable } from '../../types'
 import { useStore } from '../../store'
 import {
   useIsChatMode,
@@ -18,7 +17,6 @@ import {
 } from '@/app/components/header/account-setting/model-provider-page/declarations'
 import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
 import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run'
-import type { PromptItem } from '@/models/debug'
 import { RETRIEVAL_OUTPUT_STRUCT } from '@/app/components/workflow/constants'
 import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants'
 
@@ -29,20 +27,21 @@ const useConfig = (id: string, payload: LLMNodeType) => {
   const defaultConfig = useStore(s => s.nodesDefaultConfigs)[payload.type]
   const [defaultRolePrefix, setDefaultRolePrefix] = useState<{ user: string; assistant: string }>({ user: '', assistant: '' })
   const { inputs, setInputs: doSetInputs } = useNodeCrud<LLMNodeType>(id, payload)
+  const inputRef = useRef(inputs)
+
   const setInputs = useCallback((newInputs: LLMNodeType) => {
     if (newInputs.memory && !newInputs.memory.role_prefix) {
       const newPayload = produce(newInputs, (draft) => {
         draft.memory!.role_prefix = defaultRolePrefix
       })
       doSetInputs(newPayload)
+      inputRef.current = newPayload
       return
     }
     doSetInputs(newInputs)
+    inputRef.current = newInputs
   }, [doSetInputs, defaultRolePrefix])
-  const inputRef = useRef(inputs)
-  useEffect(() => {
-    inputRef.current = inputs
-  }, [inputs])
+
   // model
   const model = inputs.model
   const modelMode = inputs.model?.mode
@@ -178,11 +177,80 @@ const useConfig = (id: string, payload: LLMNodeType) => {
     }
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isShowVisionConfig, modelChanged])
+
   // variables
-  const { handleVarListChange, handleAddVariable } = useVarList<LLMNodeType>({
-    inputs,
-    setInputs,
-  })
+  const isShowVars = (() => {
+    if (isChatModel)
+      return (inputs.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2)
+
+    return (inputs.prompt_template as PromptItem).edition_type === EditionType.jinja2
+  })()
+  const handleAddEmptyVariable = useCallback(() => {
+    const newInputs = produce(inputRef.current, (draft) => {
+      if (!draft.prompt_config) {
+        draft.prompt_config = {
+          jinja2_variables: [],
+        }
+      }
+      if (!draft.prompt_config.jinja2_variables)
+        draft.prompt_config.jinja2_variables = []
+
+      draft.prompt_config.jinja2_variables.push({
+        variable: '',
+        value_selector: [],
+      })
+    })
+    setInputs(newInputs)
+  }, [setInputs])
+
+  const handleAddVariable = useCallback((payload: Variable) => {
+    const newInputs = produce(inputRef.current, (draft) => {
+      if (!draft.prompt_config) {
+        draft.prompt_config = {
+          jinja2_variables: [],
+        }
+      }
+      if (!draft.prompt_config.jinja2_variables)
+        draft.prompt_config.jinja2_variables = []
+
+      draft.prompt_config.jinja2_variables.push(payload)
+    })
+    setInputs(newInputs)
+  }, [setInputs])
+
+  const handleVarListChange = useCallback((newList: Variable[]) => {
+    const newInputs = produce(inputRef.current, (draft) => {
+      if (!draft.prompt_config) {
+        draft.prompt_config = {
+          jinja2_variables: [],
+        }
+      }
+      if (!draft.prompt_config.jinja2_variables)
+        draft.prompt_config.jinja2_variables = []
+
+      draft.prompt_config.jinja2_variables = newList
+    })
+    setInputs(newInputs)
+  }, [setInputs])
+
+  const handleVarNameChange = useCallback((oldName: string, newName: string) => {
+    const newInputs = produce(inputRef.current, (draft) => {
+      if (isChatModel) {
+        const promptTemplate = draft.prompt_template as PromptItem[]
+        promptTemplate.filter(item => item.edition_type === EditionType.jinja2).forEach((item) => {
+          item.jinja2_text = (item.jinja2_text || '').replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`)
+        })
+      }
+      else {
+        if ((draft.prompt_template as PromptItem).edition_type !== EditionType.jinja2)
+          return
+
+        const promptTemplate = draft.prompt_template as PromptItem
+        promptTemplate.jinja2_text = (promptTemplate.jinja2_text || '').replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`)
+      }
+    })
+    setInputs(newInputs)
+  }, [isChatModel, setInputs])
 
   // context
   const handleContextVarChange = useCallback((newVar: ValueSelector | string) => {
@@ -194,11 +262,11 @@ const useConfig = (id: string, payload: LLMNodeType) => {
   }, [inputs, setInputs])
 
   const handlePromptChange = useCallback((newPrompt: PromptItem[] | PromptItem) => {
-    const newInputs = produce(inputs, (draft) => {
+    const newInputs = produce(inputRef.current, (draft) => {
       draft.prompt_template = newPrompt
     })
     setInputs(newInputs)
-  }, [inputs, setInputs])
+  }, [setInputs])
 
   const handleMemoryChange = useCallback((newMemory?: Memory) => {
     const newInputs = produce(inputs, (draft) => {
@@ -286,6 +354,7 @@ const useConfig = (id: string, payload: LLMNodeType) => {
     runInputData,
     setRunInputData,
     runResult,
+    toVarInputs,
   } = useOneStepRun<LLMNodeType>({
     id,
     data: inputs,
@@ -295,23 +364,6 @@ const useConfig = (id: string, payload: LLMNodeType) => {
     },
   })
 
-  // const handleRun = (submitData: Record<string, any>) => {
-  //   console.log(submitData)
-  //   const res = produce(submitData, (draft) => {
-  //     debugger
-  //     if (draft.contexts) {
-  //       draft['#context#'] = draft.contexts
-  //       delete draft.contexts
-  //     }
-  //     if (draft.visionFiles) {
-  //       draft['#files#'] = draft.visionFiles
-  //       delete draft.visionFiles
-  //     }
-  //   })
-
-  //   doHandleRun(res)
-  // }
-
   const inputVarValues = (() => {
     const vars: Record<string, any> = {}
     Object.keys(runInputData)
@@ -348,7 +400,7 @@ const useConfig = (id: string, payload: LLMNodeType) => {
   }, [runInputData, setRunInputData])
 
   const allVarStrArr = (() => {
-    const arr = isChatModel ? (inputs.prompt_template as PromptItem[]).map(item => item.text) : [(inputs.prompt_template as PromptItem).text]
+    const arr = isChatModel ? (inputs.prompt_template as PromptItem[]).filter(item => item.edition_type !== EditionType.jinja2).map(item => item.text) : [(inputs.prompt_template as PromptItem).text]
     if (isChatMode && isChatModel && !!inputs.memory) {
       arr.push('{{#sys.query#}}')
       arr.push(inputs.memory.query_prompt_template)
@@ -357,7 +409,13 @@ const useConfig = (id: string, payload: LLMNodeType) => {
     return arr
   })()
 
-  const varInputs = getInputVars(allVarStrArr)
+  const varInputs = (() => {
+    const vars = getInputVars(allVarStrArr)
+    if (isShowVars)
+      return [...vars, ...toVarInputs(inputs.prompt_config?.jinja2_variables || [])]
+
+    return vars
+  })()
 
   return {
     readOnly,
@@ -370,8 +428,11 @@ const useConfig = (id: string, payload: LLMNodeType) => {
     isShowVisionConfig,
     handleModelChanged,
     handleCompletionParamsChange,
+    isShowVars,
     handleVarListChange,
+    handleVarNameChange,
     handleAddVariable,
+    handleAddEmptyVariable,
     handleContextVarChange,
     filterInputVar,
     filterVar,

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

@@ -26,6 +26,7 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({
   const {
     readOnly,
     inputs,
+    availableVars,
     handleVarListChange,
     handleVarNameChange,
     handleAddVariable,
@@ -65,7 +66,7 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({
         </Field>
         <Split />
         <CodeEditor
-          nodeId={id}
+          availableVars={availableVars}
           varList={inputs.variables}
           onAddVar={handleAddVariable}
           isInNode

+ 7 - 0
web/app/components/workflow/nodes/template-transform/use-config.ts

@@ -10,6 +10,7 @@ import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-s
 import {
   useNodesReadOnly,
 } from '@/app/components/workflow/hooks'
+import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
 
 const useConfig = (id: string, payload: TemplateTransformNodeType) => {
   const { nodesReadOnly: readOnly } = useNodesReadOnly()
@@ -22,6 +23,11 @@ const useConfig = (id: string, payload: TemplateTransformNodeType) => {
     inputsRef.current = newPayload
   }, [doSetInputs])
 
+  const { availableVars } = useAvailableVarList(id, {
+    onlyLeafNodeVar: false,
+    filterVar: () => true,
+  })
+
   const { handleAddVariable: handleAddEmptyVariable } = useVarList<TemplateTransformNodeType>({
     inputs,
     setInputs,
@@ -108,6 +114,7 @@ const useConfig = (id: string, payload: TemplateTransformNodeType) => {
   return {
     readOnly,
     inputs,
+    availableVars,
     handleVarListChange,
     handleVarNameChange,
     handleAddVariable,

+ 7 - 0
web/app/components/workflow/types.ts

@@ -131,10 +131,17 @@ export enum PromptRole {
   assistant = 'assistant',
 }
 
+export enum EditionType {
+  basic = 'basic',
+  jinja2 = 'jinja2',
+}
+
 export type PromptItem = {
   id?: string
   role?: PromptRole
   text: string
+  edition_type?: EditionType
+  jinja2_text?: string
 }
 
 export enum MemoryRole {

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

@@ -52,6 +52,8 @@ const translation = {
     jinjaEditorPlaceholder: 'Type \'/\' or \'{\' to insert variable',
     viewOnly: 'View Only',
     showRunHistory: 'Show Run History',
+    enableJinja: 'Enable Jinja template support',
+    learnMore: 'Learn More',
     copy: 'Copy',
     duplicate: 'Duplicate',
     addBlock: 'Add Block',

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

@@ -52,6 +52,8 @@ const translation = {
     jinjaEditorPlaceholder: '输入 “/” 或 “{” 插入变量',
     viewOnly: '只读',
     showRunHistory: '显示运行历史',
+    enableJinja: '开启支持 Jinja 模板',
+    learnMore: '了解更多',
     copy: '拷贝',
     duplicate: '复制',
     addBlock: '添加节点',