From 804d87bca2f9a7b2fbd341efb851d7da23778949 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 20 Jan 2026 10:42:13 +0800 Subject: [PATCH] refactor: extract jinja render's form --- .../Workflow/components/Editor/index.tsx | 36 +-- .../components/Editor/nodes/VariableNode.tsx | 2 +- .../Editor/plugin/AutocompletePlugin.tsx | 8 +- .../components/Editor/plugin/BlurPlugin.tsx | 33 +++ .../Properties/JinjaRender/index.tsx | 206 ++++++++++++++++++ .../Workflow/components/Properties/index.tsx | 145 +----------- 6 files changed, 279 insertions(+), 151 deletions(-) create mode 100644 web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx create mode 100644 web/src/views/Workflow/components/Properties/JinjaRender/index.tsx diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx index ba2e3a41..fd3e937b 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -16,6 +16,7 @@ import InitialValuePlugin from './plugin/InitialValuePlugin'; import CommandPlugin from './plugin/CommandPlugin'; import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin'; import LineNumberPlugin from './plugin/LineNumberPlugin'; +import BlurPlugin from './plugin/BlurPlugin'; import { VariableNode } from './nodes/VariableNode' interface LexicalEditorProps { @@ -113,8 +114,10 @@ const Editor: FC =({ display: flex; align-items: flex-start; } - .editor-content-with-numbers { + .editor-content-wrapper { flex: 1; + } + .editor-content-with-numbers { white-space: pre-wrap; } .editor-content-with-numbers p { @@ -174,18 +177,20 @@ const Editor: FC =({
1
- +
+ +
) : ( =({ style={{ minHeight: placeHolderMinheight, position: 'absolute', - top: variant === 'borderless' ? '0' : '6px', - left: enableJinja2 ? '59px' : (variant === 'borderless' ? '0' : '11px'), + top: enableJinja2 ? '4px' : variant === 'borderless' ? '0' : '6px', + left: enableJinja2 ? '16px' : (variant === 'borderless' ? '0' : '11px'), color: '#A8A9AA', fontSize: fontSize, lineHeight: placeHolderMinheight, @@ -227,6 +232,7 @@ const Editor: FC =({ { setCount(count) }} onChange={onChange} /> + {enableJinja2 && } ); diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx index 13d12ee1..d29fba4c 100644 --- a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -36,7 +36,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ return ( const textAfter = nodeText.substring(anchorOffset); const newText = textBefore + `{{${suggestion.value}}}` + textAfter; - anchorNode.setTextContent(newText); + if ($isTextNode(anchorNode)) { + anchorNode.setTextContent(newText); + } // 设置光标位置到插入文本之后 const newOffset = textBefore.length + `{{${suggestion.value}}}`.length; @@ -129,6 +131,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> } return (
e.preventDefault()} style={{ position: 'fixed', top: popupPosition.top, diff --git a/web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx new file mode 100644 index 00000000..b636605b --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx @@ -0,0 +1,33 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useEffect } from 'react'; +import { $setSelection } from 'lexical'; + +export default function BlurPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerRootListener((rootElement) => { + if (rootElement) { + const handleBlur = (e: FocusEvent) => { + // 检查是否点击了自动完成弹窗 + const target = e.target as HTMLElement; + console.log('target', target) + if (target?.closest('[data-autocomplete-popup="true"]')) { + return; + } + + editor.update(() => { + $setSelection(null); + }); + }; + + rootElement.addEventListener('blur', handleBlur); + return () => { + rootElement.removeEventListener('blur', handleBlur); + }; + } + }); + }, [editor]); + + return null; +} diff --git a/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx b/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx new file mode 100644 index 00000000..a2c9da37 --- /dev/null +++ b/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx @@ -0,0 +1,206 @@ +import { type FC, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { Form } from 'antd' +import { Node } from '@antv/x6' +import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' +import MappingList from '../MappingList' +import MessageEditor from '../MessageEditor' + +interface MappingItem { + name?: string + value?: string +} + +interface JinjaRenderProps { + options: Suggestion[] + templateOptions: Suggestion[] + selectedNode: Node +} + +const extractTemplateVars = (template: string): string[] => { + return (template.match(/{{\s*([\w.]+)\s*}}/g) || []) + .map(m => m.replace(/{{\s*|\s*}}/g, '')) +} + +const getMappingNames = (mapping: MappingItem[]): string[] => { + return mapping.filter(item => item?.name).map(item => item.name!) +} + +const JinjaRender: FC = ({ selectedNode, options, templateOptions }) => { + const { t } = useTranslation() + const form = Form.useFormInstance() + const values = Form.useWatch([], form) || {} + + console.log('JinjaRender values', values) + + const prevMappingNamesRef = useRef([]) + const prevTemplateVarsRef = useRef([]) + const syncTimeoutRef = useRef(null) + const isSyncingRef = useRef(false) + const lastSyncSourceRef = useRef<'mapping' | 'template' | null>(null) + + // Reset refs when node changes + useEffect(() => { + if (selectedNode?.getData()?.id) { + prevMappingNamesRef.current = [] + prevTemplateVarsRef.current = [] + lastSyncSourceRef.current = null + } + }, [selectedNode?.getData()?.id]) + + // Sync template when mapping names change + useEffect(() => { + if ( + isSyncingRef.current || + lastSyncSourceRef.current === 'mapping' || + selectedNode?.data?.type !== 'jinja-render' || + !values?.mapping || + !values?.template + ) return + + const currentMappingNames = Array.isArray(values.mapping) ? getMappingNames(values.mapping) : [] + const prevNames = prevMappingNamesRef.current + + if (prevNames.length === 0) { + prevMappingNamesRef.current = currentMappingNames + return + } + + if (JSON.stringify(prevNames) === JSON.stringify(currentMappingNames)) return + + if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current) + const activeElement = document.activeElement as HTMLElement + + syncTimeoutRef.current = setTimeout(() => { + let updatedTemplate = String(form.getFieldValue('template') || '') + + prevNames.forEach((oldName, index) => { + const newName = currentMappingNames[index] + if (newName && oldName !== newName) { + updatedTemplate = updatedTemplate.replace( + new RegExp(`{{\\s*${oldName}\\s*}}`, 'g'), + `{{${newName}}}` + ) + } + }) + + if (updatedTemplate !== form.getFieldValue('template')) { + isSyncingRef.current = true + lastSyncSourceRef.current = 'mapping' + + prevTemplateVarsRef.current = extractTemplateVars(updatedTemplate) + prevMappingNamesRef.current = currentMappingNames + form.setFieldValue('template', updatedTemplate) + + requestAnimationFrame(() => { + activeElement?.focus?.() + setTimeout(() => { + isSyncingRef.current = false + lastSyncSourceRef.current = null + }, 50) + }) + } else { + prevMappingNamesRef.current = currentMappingNames + } + }, 0) + }, [values?.mapping, selectedNode?.data?.type, form]) + + // Sync mapping when template variables change + useEffect(() => { + console.log('values?.template', values?.template) + if ( + isSyncingRef.current || + lastSyncSourceRef.current === 'template' || + selectedNode?.data?.type !== 'jinja-render' || + !values?.template || + !values?.mapping + ) return + + const templateVars = extractTemplateVars(String(values.template)) + if (JSON.stringify(prevTemplateVarsRef.current) === JSON.stringify(templateVars)) return + + const isTemplateEditor = document.activeElement?.closest('[data-editor-type="template"]') + if (!isTemplateEditor) { + prevTemplateVarsRef.current = templateVars + return + } + + const updatedMapping: MappingItem[] = Array.isArray(values.mapping) + ? [...values.mapping.filter((item: MappingItem) => item)] + : [] + const existingNames = getMappingNames(updatedMapping) + let updatedTemplate = String(values.template) + + // Update existing mapping names based on position + if (prevTemplateVarsRef.current.length > 0) { + prevTemplateVarsRef.current.forEach((oldVar, index) => { + const newVar = templateVars[index] + if (newVar && oldVar !== newVar && updatedMapping[index]) { + updatedMapping[index] = { ...updatedMapping[index], name: newVar } + } + }) + } + + // Add new mappings and normalize template + templateVars.forEach(varName => { + const existingMapping = updatedMapping.find(item => item.value === `{{${varName}}}`) + const regex = new RegExp(`{{\\s*${varName.replace(/\./g, '\\.')}\\s*}}`, 'g') + + if (existingMapping) { + updatedTemplate = updatedTemplate.replace(regex, `{{${existingMapping.name}}}`) + } else if (!existingNames.includes(varName)) { + const mappingName = varName.includes('.') ? varName.split('.').pop() || varName : varName + updatedMapping.push({ name: mappingName, value: `{{${varName}}}` }) + updatedTemplate = updatedTemplate.replace(regex, `{{${mappingName}}}`) + } + }) + + // Remove unused mappings and duplicates + const seenNames = new Set() + const finalMapping = updatedMapping.filter(item => { + const isUsed = templateVars.some(v => item.name === v || item.value === `{{${v}}}`) + if (!isUsed || !item.name || seenNames.has(item.name)) return false + seenNames.add(item.name) + return true + }) + + isSyncingRef.current = true + lastSyncSourceRef.current = 'template' + prevMappingNamesRef.current = getMappingNames(finalMapping) + prevTemplateVarsRef.current = templateVars + + if (JSON.stringify(finalMapping) !== JSON.stringify(values.mapping)) { + form.setFieldValue('mapping', finalMapping) + } + if (updatedTemplate !== String(values.template)) { + form.setFieldValue('template', updatedTemplate) + } + + setTimeout(() => { + isSyncingRef.current = false + lastSyncSourceRef.current = null + }, 50) + }, [values?.template, selectedNode?.data?.type, form]) + + return ( + <> + + + + + + + + + ) +} + +export default JinjaRender diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 6d4571dc..d55e1d9e 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useState, useRef, useMemo } from "react"; +import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' import { useTranslation } from 'react-i18next' import { Graph, Node } from '@antv/x6'; @@ -17,7 +17,6 @@ import ParamsList from './ParamsList'; import GroupVariableList from './GroupVariableList' import CaseList from './CaseList' import HttpRequest from './HttpRequest'; -import MappingList from './MappingList' import CategoryList from './CategoryList' import ConditionList from './ConditionList' import CycleVarsList from './CycleVarsList' @@ -29,6 +28,7 @@ import { useVariableList, getCurrentNodeVariables } from './hooks/useVariableLis import styles from './properties.module.css' import Editor from "../Editor"; import RbSlider from './RbSlider' +import JinjaRender from './JinjaRender' interface PropertiesProps { selectedNode?: Node | null; @@ -50,136 +50,16 @@ const Properties: FC = ({ const [form] = Form.useForm(); const [configs, setConfigs] = useState>({} as Record) const values = Form.useWatch([], form); - const prevMappingNamesRef = useRef([]) - const prevTemplateVarsRef = useRef([]) - const syncTimeoutRef = useRef(null) - const isSyncingRef = useRef(false) - const lastSyncSourceRef = useRef<'mapping' | 'template' | null>(null) const variableList = useVariableList(selectedNode, graphRef, chatVariables) useEffect(() => { if (selectedNode?.getData()?.id) { - form.resetFields() - prevMappingNamesRef.current = [] - prevTemplateVarsRef.current = [] - lastSyncSourceRef.current = null setOutputCollapsed(true) + } else { + form.resetFields() } }, [selectedNode?.getData()?.id]) - // Sync template when mapping names change - useEffect(() => { - if (isSyncingRef.current || lastSyncSourceRef.current === 'mapping' || selectedNode?.data?.type !== 'jinja-render' || !values?.mapping || !values?.template) return - - const currentMappingNames = Array.isArray(values.mapping) ? values.mapping.filter(item => item && item.name).map((item: any) => item.name) : [] - const prevNames = prevMappingNamesRef.current - - if (prevNames.length === 0) { - prevMappingNamesRef.current = currentMappingNames - return - } - - if (JSON.stringify(prevNames) === JSON.stringify(currentMappingNames)) return - - if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current) - const activeElement = document.activeElement as HTMLElement - - syncTimeoutRef.current = setTimeout(() => { - let updatedTemplate = String(form.getFieldValue('template') || '') - - prevNames.forEach((oldName, index) => { - const newName = currentMappingNames[index] - if (newName && oldName !== newName) { - updatedTemplate = updatedTemplate.replace(new RegExp(`{{\\s*${oldName}\\s*}}`, 'g'), `{{${newName}}}`) - } - }) - - if (updatedTemplate !== form.getFieldValue('template')) { - isSyncingRef.current = true - lastSyncSourceRef.current = 'mapping' - const newTemplateVars = (updatedTemplate.match(/{{\s*([\w.]+)\s*}}/g) || []).map(m => m.replace(/{{\s*|\s*}}/g, '')) - prevTemplateVarsRef.current = newTemplateVars - prevMappingNamesRef.current = currentMappingNames - form.setFieldValue('template', updatedTemplate) - - requestAnimationFrame(() => { - activeElement?.focus?.() - setTimeout(() => { - isSyncingRef.current = false - lastSyncSourceRef.current = null - }, 50) - }) - } else { - prevMappingNamesRef.current = currentMappingNames - } - }, 0) - }, [values?.mapping, selectedNode?.data?.type, form]) - - // Sync mapping when template variables change - useEffect(() => { - if (isSyncingRef.current || lastSyncSourceRef.current === 'template' || selectedNode?.data?.type !== 'jinja-render' || !values?.template || !values?.mapping) return - - const templateVars = (String(values.template).match(/{{\s*([\w.]+)\s*}}/g) || []).map(m => m.replace(/{{\s*|\s*}}/g, '')) - if (JSON.stringify(prevTemplateVarsRef.current) === JSON.stringify(templateVars)) return - - const isTemplateEditor = document.activeElement?.closest('[data-editor-type="template"]') - if (!isTemplateEditor) { - prevTemplateVarsRef.current = templateVars - return - } - - const updatedMapping = Array.isArray(values.mapping) ? [...values.mapping.filter(item => item)] : [] - const existingNames = updatedMapping.filter(item => item && item.name).map(item => item.name) - let updatedTemplate = String(values.template) - - if (prevTemplateVarsRef.current.length > 0) { - prevTemplateVarsRef.current.forEach((oldVar, index) => { - const newVar = templateVars[index] - if (newVar && oldVar !== newVar && updatedMapping[index]) { - updatedMapping[index] = { ...updatedMapping[index], name: newVar } - } - }) - } - - templateVars.forEach(varName => { - const existingMapping = updatedMapping.find(item => item.value === `{{${varName}}}`) - const regex = new RegExp(`{{\\s*${varName.replace(/\./g, '\\.')}\\s*}}`, 'g') - - if (existingMapping) { - updatedTemplate = updatedTemplate.replace(regex, `{{${existingMapping.name}}}`) - } else if (!existingNames.includes(varName)) { - const mappingName = varName.includes('.') ? varName.split('.').pop() || varName : varName - updatedMapping.push({ name: mappingName, value: `{{${varName}}}` }) - updatedTemplate = updatedTemplate.replace(regex, `{{${mappingName}}}`) - } - }) - - const seenNames = new Set() - const finalMapping = updatedMapping.filter(item => { - const isUsed = templateVars.some(v => item.name === v || item.value === `{{${v}}}`) - if (!isUsed || seenNames.has(item.name)) return false - seenNames.add(item.name) - return true - }) - - isSyncingRef.current = true - lastSyncSourceRef.current = 'template' - prevMappingNamesRef.current = finalMapping.filter(item => item && item.name).map((item: any) => item.name) - prevTemplateVarsRef.current = templateVars - - if (JSON.stringify(finalMapping) !== JSON.stringify(values.mapping)) { - form.setFieldValue('mapping', finalMapping) - } - if (updatedTemplate !== String(values.template)) { - form.setFieldValue('template', updatedTemplate) - } - - setTimeout(() => { - isSyncingRef.current = false - lastSyncSourceRef.current = null - }, 50) - }, [values?.template, selectedNode?.data?.type, form]) - useEffect(() => { if (selectedNode && form) { const { type = 'default', name = '', config } = selectedNode.getData() || {} @@ -197,6 +77,8 @@ const Properties: FC = ({ ...initialValue, }) setConfigs(config || {}) + } else { + form.resetFields() } }, [selectedNode, form]) @@ -529,6 +411,12 @@ const Properties: FC = ({ /> : selectedNode?.data?.type === 'tool' ? + : selectedNode?.data.type === 'jinja-render' + ? : configs && Object.keys(configs).length > 0 && Object.keys(configs).map((key) => { const config = configs[key] || {} @@ -646,15 +534,6 @@ const Properties: FC = ({ ) } - - if (config.type === 'mappingList') { - return ( - - - - - ) - } if (config.type === 'cycleVarsList') { return (