jiaxing.liao 3 дней назад
Родитель
Сommit
8123f3d2e1
46 измененных файлов с 4757 добавлено и 1032 удалено
  1. 6 30
      apps/web/components.d.ts
  2. 2 2
      apps/web/package.json
  3. 0 18
      apps/web/src/components/PromptEditor/editorConfig.ts
  4. 176 12
      apps/web/src/components/PromptEditor/index.vue
  5. 0 5
      apps/web/src/components/PromptEditor/plugins/AutoFocusPlugin.vue
  6. 50 0
      apps/web/src/components/PromptEditor/plugins/CustomText.ts
  7. 0 42
      apps/web/src/components/PromptEditor/plugins/TagPlugin.ts
  8. 121 0
      apps/web/src/components/PromptEditor/plugins/VarLabel.tsx
  9. 51 0
      apps/web/src/components/PromptEditor/plugins/VarLabelBlock.vue
  10. 426 0
      apps/web/src/components/PromptEditor/plugins/VarLaberPickerPlugin.vue
  11. 186 0
      apps/web/src/components/PromptEditor/utils.ts
  12. 93 0
      apps/web/src/components/VarLabel/index.vue
  13. 60 1
      apps/web/src/constant/index.ts
  14. 18 30
      apps/web/src/features/setter/index.vue
  15. 1 1
      apps/web/src/features/toolbar/AgentEnvDialog.vue
  16. 10 1
      apps/web/src/nodes/Interface.ts
  17. 20 77
      apps/web/src/nodes/_base/VarSelect.vue
  18. 28 28
      apps/web/src/nodes/_base/condition/ConditionItem.vue
  19. 1 1
      apps/web/src/nodes/_base/condition/index.vue
  20. 16 2
      apps/web/src/nodes/src/condition/index.ts
  21. 34 31
      apps/web/src/nodes/src/condition/setter.vue
  22. 3 0
      apps/web/src/nodes/src/http/index.ts
  23. 1 1
      apps/web/src/nodes/src/http/setter.vue
  24. 12 12
      apps/web/src/router/index.ts
  25. 22 0
      apps/web/src/types/var.d.ts
  26. 2 3
      apps/web/src/utils/uuid.ts
  27. 18 0
      apps/web/src/utils/varReg.ts
  28. 36 10
      apps/web/src/views/Dashboard.vue
  29. 48 21
      apps/web/src/views/Editor.vue
  30. 1 0
      apps/web/vite.config.ts
  31. 16 5
      packages/api-client/request.ts
  32. 3083 561
      packages/api-service/agent.openapi.json
  33. 165 76
      packages/api-service/servers/api/agent.ts
  34. 4 0
      packages/api-service/servers/api/typings.d.ts
  35. 0 19
      packages/workflow/index.html
  36. 0 1
      packages/workflow/src/assets/vue.svg
  37. 1 0
      packages/workflow/src/components/Canvas.vue
  38. 2 1
      packages/workflow/src/components/elements/handles/HandlePort.vue
  39. 10 4
      packages/workflow/src/components/elements/nodes/CanvasNode.vue
  40. 13 5
      packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue
  41. 13 4
      packages/workflow/src/components/elements/nodes/render-types/NodeLoop.vue
  42. 3 1
      packages/workflow/src/components/elements/nodes/render-types/NodeRenderer.vue
  43. 3 2
      packages/workflow/src/hooks/useDragAndDrop.ts
  44. 0 10
      packages/workflow/uno.config.ts
  45. 0 8
      packages/workflow/vite.config.ts
  46. 2 7
      pnpm-lock.yaml

+ 6 - 30
apps/web/components.d.ts

@@ -13,12 +13,6 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     AutoFocusPlugin: typeof import('./src/components/PromptEditor/plugins/AutoFocusPlugin.vue')['default']
-    BranchCard: typeof import('./src/components/SetterCommon/condition/BranchCard.vue')['default']
-    CodeEditor: typeof import('./src/components/SetterCommon/Code/CodeEditor.vue')['default']
-    CodeSetter: typeof import('./src/components/setter/CodeSetter.vue')['default']
-    ConditionBuilder: typeof import('./src/components/SetterCommon/condition/ConditionBuilder.vue')['default']
-    ConditionSetter: typeof import('./src/components/setter/ConditionSetter.vue')['default']
-    DatabaseSetter: typeof import('./src/components/setter/DatabaseSetter.vue')['default']
     ElAside: typeof import('element-plus/es')['ElAside']
     ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
     ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
@@ -34,7 +28,6 @@ declare module 'vue' {
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
-    ElEmpty: typeof import('element-plus/es')['ElEmpty']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
@@ -63,23 +56,18 @@ declare module 'vue' {
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
-    ErrorHandling: typeof import('./src/components/SetterCommon/Code/ErrorHandling.vue')['default']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
-    HttpSetter: typeof import('./src/components/setter/HttpSetter.vue')['default']
-    InputVariables: typeof import('./src/components/SetterCommon/Code/InputVariables.vue')['default']
-    OnChangePlugin: typeof import('./src/components/PromptEditor/plugins/OnChangePlugin.vue')['default']
-    OutputVariables: typeof import('./src/components/SetterCommon/Code/OutputVariables.vue')['default']
     PromptEditor: typeof import('./src/components/PromptEditor/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     RunWorkflow: typeof import('./src/components/RunWorkflow/index.vue')['default']
     SearchDialog: typeof import('./src/components/SearchDialog/index.vue')['default']
-    Setter: typeof import('./src/components/setter/index.vue')['default']
     Sidebar: typeof import('./src/components/Sidebar/index.vue')['default']
     SvgIcon: typeof import('./src/components/SvgIcon/index.vue')['default']
-    TagPlugin: typeof import('./src/components/PromptEditor/plugins/TagPlugin.vue')['default']
     TemplateModal: typeof import('./src/components/TemplateModal/index.vue')['default']
-    TestConfig: typeof import('./src/components/SetterCommon/Code/TestConfig.vue')['default']
+    VarLabel: typeof import('./src/components/VarLabel/index.vue')['default']
+    VarLabelBlock: typeof import('./src/components/PromptEditor/plugins/VarLabelBlock.vue')['default']
+    VarLaberPickerPlugin: typeof import('./src/components/PromptEditor/plugins/VarLaberPickerPlugin.vue')['default']
   }
   export interface GlobalDirectives {
     vLoading: typeof import('element-plus/es')['ElLoadingDirective']
@@ -89,12 +77,6 @@ declare module 'vue' {
 // For TSX support
 declare global {
   const AutoFocusPlugin: typeof import('./src/components/PromptEditor/plugins/AutoFocusPlugin.vue')['default']
-  const BranchCard: typeof import('./src/components/SetterCommon/condition/BranchCard.vue')['default']
-  const CodeEditor: typeof import('./src/components/SetterCommon/Code/CodeEditor.vue')['default']
-  const CodeSetter: typeof import('./src/components/setter/CodeSetter.vue')['default']
-  const ConditionBuilder: typeof import('./src/components/SetterCommon/condition/ConditionBuilder.vue')['default']
-  const ConditionSetter: typeof import('./src/components/setter/ConditionSetter.vue')['default']
-  const DatabaseSetter: typeof import('./src/components/setter/DatabaseSetter.vue')['default']
   const ElAside: typeof import('element-plus/es')['ElAside']
   const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
   const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
@@ -110,7 +92,6 @@ declare global {
   const ElDropdown: typeof import('element-plus/es')['ElDropdown']
   const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
   const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
-  const ElEmpty: typeof import('element-plus/es')['ElEmpty']
   const ElForm: typeof import('element-plus/es')['ElForm']
   const ElFormItem: typeof import('element-plus/es')['ElFormItem']
   const ElIcon: typeof import('element-plus/es')['ElIcon']
@@ -139,21 +120,16 @@ declare global {
   const ElTabs: typeof import('element-plus/es')['ElTabs']
   const ElTag: typeof import('element-plus/es')['ElTag']
   const ElTooltip: typeof import('element-plus/es')['ElTooltip']
-  const ErrorHandling: typeof import('./src/components/SetterCommon/Code/ErrorHandling.vue')['default']
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
-  const HttpSetter: typeof import('./src/components/setter/HttpSetter.vue')['default']
-  const InputVariables: typeof import('./src/components/SetterCommon/Code/InputVariables.vue')['default']
-  const OnChangePlugin: typeof import('./src/components/PromptEditor/plugins/OnChangePlugin.vue')['default']
-  const OutputVariables: typeof import('./src/components/SetterCommon/Code/OutputVariables.vue')['default']
   const PromptEditor: typeof import('./src/components/PromptEditor/index.vue')['default']
   const RouterLink: typeof import('vue-router')['RouterLink']
   const RouterView: typeof import('vue-router')['RouterView']
   const RunWorkflow: typeof import('./src/components/RunWorkflow/index.vue')['default']
   const SearchDialog: typeof import('./src/components/SearchDialog/index.vue')['default']
-  const Setter: typeof import('./src/components/setter/index.vue')['default']
   const Sidebar: typeof import('./src/components/Sidebar/index.vue')['default']
   const SvgIcon: typeof import('./src/components/SvgIcon/index.vue')['default']
-  const TagPlugin: typeof import('./src/components/PromptEditor/plugins/TagPlugin.vue')['default']
   const TemplateModal: typeof import('./src/components/TemplateModal/index.vue')['default']
-  const TestConfig: typeof import('./src/components/SetterCommon/Code/TestConfig.vue')['default']
+  const VarLabel: typeof import('./src/components/VarLabel/index.vue')['default']
+  const VarLabelBlock: typeof import('./src/components/PromptEditor/plugins/VarLabelBlock.vue')['default']
+  const VarLaberPickerPlugin: typeof import('./src/components/PromptEditor/plugins/VarLaberPickerPlugin.vue')['default']
 }

+ 2 - 2
apps/web/package.json

@@ -5,7 +5,7 @@
   "type": "module",
   "scripts": {
     "dev": "vite",
-    "build": "vue-tsc -b && vite build",
+    "build": "vite build",
     "preview": "vite preview"
   },
   "dependencies": {
@@ -15,7 +15,7 @@
     "axios": "^1.13.2",
     "echarts": "^6.0.0",
     "element-plus": "^2.13.1",
-    "lexical": "^0.41.0",
+    "lexical": "0.38.1",
     "lexical-vue": "^0.14.1",
     "lodash-es": "^4.17.21",
     "monaco-editor": "^0.55.1",

+ 0 - 18
apps/web/src/components/PromptEditor/editorConfig.ts

@@ -1,18 +0,0 @@
-import { type InitialConfigType } from 'lexical-vue'
-
-const editorConfig: InitialConfigType = {
-	namespace: 'PromptEditor',
-	theme: {
-		ltr: 'ltr',
-		rtl: 'rtl',
-		placeholder: 'editor-placeholder',
-		paragraph: 'editor-paragraph'
-	},
-	editable: true,
-	onError(error: Error) {
-		throw error
-	},
-	nodes: []
-}
-
-export default editorConfig

+ 176 - 12
apps/web/src/components/PromptEditor/index.vue

@@ -1,30 +1,130 @@
 <script setup lang="ts">
+import { computed, inject, ref, type Ref } from 'vue'
 import { LexicalComposer } from 'lexical-vue/LexicalComposer'
 import { ContentEditable } from 'lexical-vue/LexicalContentEditable'
 import { HistoryPlugin } from 'lexical-vue/LexicalHistoryPlugin'
-import { PlainTextPlugin } from 'lexical-vue/LexicalPlainTextPlugin'
-import editorConfig from './editorConfig'
+import { RichTextPlugin } from 'lexical-vue/LexicalRichTextPlugin'
+import { OnChangePlugin } from 'lexical-vue/LexicalOnChangePlugin'
+import { $getRoot } from 'lexical'
+import { textToEditorState } from './utils'
+import VarLabelNodePlugin from './plugins/VarLabelBlock.vue'
+import VarLaberPickerPlugin from './plugins/VarLaberPickerPlugin.vue'
+import { VarLabelNode } from './plugins/VarLabel'
 
-const props = defineProps<{
-	modelValue?: string
-	placeholder?: string
+import type { PromptEditorMenuOption } from './plugins/VarLaberPickerPlugin.vue'
+import type { InitialConfigType } from 'lexical-vue'
+import type { NodeVar } from '@/types/var'
+
+const props = withDefaults(
+	defineProps<{
+		modelValue?: string
+		placeholder?: string
+		autoFocus?: boolean
+		triggerStrings?: string[]
+		menuOptions?: PromptEditorMenuOption[]
+	}>(),
+	{
+		modelValue: '',
+		placeholder: '',
+		autoFocus: false,
+		triggerStrings: () => ['/'],
+		menuOptions: () => []
+	}
+)
+
+const emit = defineEmits<{
+	'update:modelValue': [value: string]
+	focus: [event: FocusEvent]
+	blur: [event: FocusEvent]
 }>()
+
+const editorConfig: InitialConfigType = {
+	namespace: 'PromptEditor',
+	theme: {
+		ltr: 'ltr',
+		rtl: 'rtl',
+		placeholder: 'editor-placeholder',
+		paragraph: 'editor-paragraph'
+	},
+	editable: true,
+	editorState: textToEditorState(props.modelValue),
+	onError(error: Error) {
+		throw error
+	},
+	nodes: [VarLabelNode]
+}
+
+const isFocused = ref(false)
+const nodeVars = inject<Ref<NodeVar[]>>('nodeVars', ref([]))
+
+const fallbackMenuOptions = computed<PromptEditorMenuOption[]>(() => {
+	return (nodeVars?.value || []).flatMap((group) => {
+		return group.variableList.map((variable) => {
+			const value = variable.expression.startsWith('#{')
+				? variable.expression
+				: `#{${variable.expression}}`
+			const type = group.id === 'env' ? 'env' : group.id === 'sys' ? 'sys' : 'node'
+
+			return {
+				key: `${group.id}-${variable.expression}`,
+				label: variable.name,
+				value,
+				type,
+				groupId: group.id,
+				groupName: group.name,
+				nodeType: group.type,
+				valueType: variable.type
+			}
+		})
+	})
+})
+
+const pickerOptions = computed<PromptEditorMenuOption[]>(() => {
+	if (props.menuOptions.length) return props.menuOptions
+	return fallbackMenuOptions.value
+})
+
+function onFocus(e: FocusEvent) {
+	isFocused.value = true
+	emit('focus', e)
+}
+
+function onBlur(e: FocusEvent) {
+	isFocused.value = false
+	emit('blur', e)
+}
+
+function onChange(editorState: any) {
+	const text = editorState.read(() => {
+		return $getRoot()
+			.getChildren()
+			.map((p) => p.getTextContent())
+			.join('\n')
+	})
+
+	emit('update:modelValue', text)
+}
 </script>
 
 <template>
 	<LexicalComposer :initial-config="editorConfig">
-		<div class="el-input__wrapper">
-			<PlainTextPlugin>
+		<div class="el-input__wrapper" :class="{ 'is-focus': isFocused }">
+			<RichTextPlugin>
 				<template #contentEditable>
-					<ContentEditable class="el-input__inner">
+					<ContentEditable class="el-input__inner" @focus="onFocus" @blur="onBlur">
 						<template #placeholder>
 							<div class="editor-placeholder">{{ placeholder }}</div>
 						</template>
 					</ContentEditable>
 				</template>
-			</PlainTextPlugin>
+			</RichTextPlugin>
 		</div>
 		<HistoryPlugin />
+		<AutoFocusPlugin v-if="autoFocus" />
+		<OnChangePlugin :onChange="onChange" />
+		<VarLaberPickerPlugin :trigger-strings="triggerStrings" :options="pickerOptions" />
+		<VarLabelNodePlugin />
+		<slot></slot>
 	</LexicalComposer>
 </template>
 
@@ -39,10 +139,16 @@ const props = defineProps<{
 
 .el-input__wrapper {
 	width: 100%;
+	box-sizing: border-box;
+	&.is-focus,
+	&:hover {
+		box-shadow: 0 0 0 1px var(--el-color-primary) inset;
+	}
 }
+
 :deep(.el-input__inner) {
 	text-align: left;
-	min-height: 32px;
+	min-height: 30px;
 	resize: none;
 	font-size: 15px;
 	caret-color: rgb(5, 5, 5);
@@ -50,18 +156,76 @@ const props = defineProps<{
 	tab-size: 1;
 	outline: 0;
 	caret-color: #444;
+
 	p {
 		margin: 0;
-		line-height: 32px;
+		line-height: 30px;
 	}
 }
 
+:deep(.var-label-inner) {
+	display: inline-flex;
+	align-items: center;
+	height: 22px;
+	padding: 0 8px;
+	margin: 0 2px;
+	border-radius: 6px;
+	border: 1px solid transparent;
+	font-size: 12px;
+	line-height: 20px;
+	box-sizing: border-box;
+	vertical-align: middle;
+	gap: 4px;
+}
+
+:deep(.var-label-inner[data-tag-type='env']::before),
+:deep(.var-label-inner[data-tag-type='sys']::before) {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	min-width: 18px;
+	height: 14px;
+	padding: 0 4px;
+	border-radius: 4px;
+	font-size: 10px;
+	line-height: 14px;
+	font-weight: 600;
+	background: currentColor;
+	color: #fff;
+	content: attr(data-tag-type);
+}
+
+:deep(.env-label .var-label-inner) {
+	color: #6366f1;
+	background: #eef2ff;
+	border-color: #c7d2fe;
+}
+
+:deep(.sys-label .var-label-inner) {
+	color: #ea580c;
+	background: #fff7ed;
+	border-color: #fed7aa;
+}
+
+:deep(.node-label .var-label-inner) {
+	color: #0f766e;
+	background: #f0fdfa;
+	border-color: #99f6e4;
+}
+
+:deep(.custom-label .var-label-inner) {
+	color: #334155;
+	background: #f8fafc;
+	border-color: #cbd5e1;
+}
+
 .editor-placeholder {
+	line-height: 30px;
 	color: #999;
 	overflow: hidden;
 	position: absolute;
 	text-overflow: ellipsis;
-	top: 7px;
+	top: 0;
 	left: 10px;
 	font-size: 15px;
 	user-select: none;

+ 0 - 5
apps/web/src/components/PromptEditor/plugins/AutoFocusPlugin.vue

@@ -2,14 +2,9 @@
 import { useLexicalComposer } from 'lexical-vue'
 import { onMounted } from 'vue'
 
-// Lexical Vue plugins are Vue components, which makes them
-// highly composable. Furthermore, you can lazy load plugins if
-// desired, so you don't pay the cost for plugins until you
-// actually use them.
 const editor = useLexicalComposer()
 
 onMounted(() => {
-	// Focus the editor when the effect fires!
 	editor.focus()
 })
 </script>

+ 50 - 0
apps/web/src/components/PromptEditor/plugins/CustomText.ts

@@ -0,0 +1,50 @@
+import type { EditorConfig, SerializedTextNode } from 'lexical'
+import { $createTextNode, TextNode } from 'lexical'
+
+export class CustomTextNode extends TextNode {
+	static getType() {
+		return 'custom-text'
+	}
+
+	static clone(node: CustomTextNode) {
+		return new CustomTextNode(node.__text, node.__key)
+	}
+
+	// constructor(text: string, key?: NodeKey) {
+	//   super(text, key)
+	// }
+
+	createDOM(config: EditorConfig) {
+		const dom = super.createDOM(config)
+		return dom
+	}
+
+	static importJSON(serializedNode: SerializedTextNode): TextNode {
+		const node = $createTextNode(serializedNode.text)
+		node.setFormat(serializedNode.format)
+		node.setDetail(serializedNode.detail)
+		node.setMode(serializedNode.mode)
+		node.setStyle(serializedNode.style)
+		return node
+	}
+
+	exportJSON(): SerializedTextNode {
+		return {
+			detail: this.getDetail(),
+			format: this.getFormat(),
+			mode: this.getMode(),
+			style: this.getStyle(),
+			text: this.getTextContent(),
+			type: 'custom-text',
+			version: 1
+		}
+	}
+
+	isSimpleText() {
+		return (this.__type === 'text' || this.__type === 'custom-text') && this.__mode === 0
+	}
+}
+
+export function $createCustomTextNode(text: string): CustomTextNode {
+	return new CustomTextNode(text)
+}

+ 0 - 42
apps/web/src/components/PromptEditor/plugins/TagPlugin.ts

@@ -1,42 +0,0 @@
-import { TextNode } from 'lexical'
-
-export class TagNode extends TextNode {
-	static getType() {
-		return 'tag'
-	}
-
-	static clone(node) {
-		return new TagNode(node.__className, node.__text, node.__key)
-	}
-
-	constructor(className: string, text: string, key?: string) {
-		super(text, key)
-		this.__className = className
-	}
-
-	createDOM(config) {
-		const dom = document.createElement('span')
-		const inner = super.createDOM(config)
-		dom.className = this.__className
-		inner.className = 'emoji-inner'
-		dom.appendChild(inner)
-		return dom
-	}
-
-	updateDOM(prevNode, dom, config) {
-		const inner = dom.firstChild
-		if (inner === null) {
-			return true
-		}
-		super.updateDOM(prevNode, inner, config)
-		return false
-	}
-}
-
-export function $isTagNode(node) {
-	return node instanceof TagNode
-}
-
-export function $createTagNode(className: string, tagText: string) {
-	return new TagNode(className, tagText).setMode('token')
-}

+ 121 - 0
apps/web/src/components/PromptEditor/plugins/VarLabel.tsx

@@ -0,0 +1,121 @@
+import { TextNode } from 'lexical'
+import type { EditorConfig, SerializedTextNode } from 'lexical'
+
+export type SerializedVarLabelNode = SerializedTextNode & {
+	type: 'var-label'
+	className: string
+	key?: string
+}
+
+function getVarLabelType(className: string) {
+	if (className.includes('env-label')) return 'env'
+	if (className.includes('sys-label')) return 'sys'
+	if (className.includes('node-label')) return 'node'
+	return 'custom'
+}
+
+function getDisplayText(rawText: string) {
+	if (rawText.startsWith('#{env.') && rawText.endsWith('}')) {
+		return rawText.slice(6, -1)
+	}
+
+	if (rawText.startsWith('#{sys.') && rawText.endsWith('}')) {
+		return rawText.slice(6, -1)
+	}
+
+	if (rawText.startsWith('#{') && rawText.endsWith('}')) {
+		const expression = rawText.slice(2, -1)
+		const splitIndex = expression.indexOf('.')
+		if (splitIndex > -1) {
+			return expression.slice(splitIndex + 1)
+		}
+		return expression
+	}
+
+	return rawText
+}
+
+function updateInnerPresentation(inner: HTMLElement, className: string, rawText: string) {
+	inner.className = 'var-label-inner'
+	inner.setAttribute('data-tag-type', getVarLabelType(className))
+	inner.textContent = getDisplayText(rawText)
+}
+
+export class VarLabelNode extends TextNode {
+	__className: string
+
+	static getType() {
+		return 'var-label'
+	}
+
+	static clone(node: VarLabelNode) {
+		return new VarLabelNode(node.__className, node.__text, node.__key)
+	}
+
+	static importJSON(serializedNode: SerializedTextNode): VarLabelNode {
+		const { className, text, key } = serializedNode as SerializedVarLabelNode
+		const node = new VarLabelNode(className, text, key)
+		node.setMode('token')
+		return node
+	}
+
+	constructor(className: string, text: string, key?: string) {
+		super(text, key)
+		this.__className = className
+	}
+
+	isTextEntity() {
+		return true
+	}
+
+	canInsertTextBefore() {
+		return false
+	}
+
+	canInsertTextAfter() {
+		return false
+	}
+
+	createDOM(config: EditorConfig) {
+		const dom = document.createElement('span')
+		const inner = super.createDOM(config)
+		dom.className = this.__className
+		dom.contentEditable = 'false'
+		updateInnerPresentation(inner, this.__className, this.__text)
+		dom.appendChild(inner)
+		return dom
+	}
+
+	updateDOM(prevNode: VarLabelNode, dom: ChildNode, config: EditorConfig) {
+		const inner = dom.firstChild
+		if (inner === null) {
+			return true
+		}
+
+		if (prevNode.__className !== this.__className) {
+			;(dom as HTMLElement).className = this.__className
+		}
+		;(dom as HTMLElement).contentEditable = 'false'
+
+		super.updateDOM(prevNode as this, inner as HTMLElement, config)
+		updateInnerPresentation(inner as HTMLElement, this.__className, this.__text)
+		return false
+	}
+
+	exportJSON(): SerializedVarLabelNode {
+		return {
+			...super.exportJSON(),
+			type: 'var-label',
+			className: this.__className,
+			version: 1
+		}
+	}
+}
+
+export function $isVarLabelNode(node: unknown): node is VarLabelNode {
+	return node instanceof VarLabelNode
+}
+
+export function $createVarLabelNode(className: string, text: string) {
+	return new VarLabelNode(className, text).setMode('token')
+}

+ 51 - 0
apps/web/src/components/PromptEditor/plugins/VarLabelBlock.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import { $createVarLabelNode } from './VarLabel'
+import { $createTextNode, TextNode } from 'lexical'
+import { onMounted, onUnmounted } from 'vue'
+import { useLexicalComposer } from 'lexical-vue'
+import { splitTextByVariableSegments } from '../utils'
+import { $isVarLabelNode } from './VarLabel'
+
+function varLabelTransform(node: TextNode) {
+	if ($isVarLabelNode(node)) return
+
+	const textContent = node.getTextContent()
+	const segments = splitTextByVariableSegments(textContent)
+	const hasVariable = segments.some((segment) => segment.kind === 'variable')
+	if (!hasVariable) return
+
+	const nodes = segments
+		.map((segment) => {
+			if (segment.kind === 'variable') {
+				return $createVarLabelNode(`${segment.varType}-label`, segment.text)
+			}
+			if (!segment.text.length) return null
+			return $createTextNode(segment.text)
+		})
+		.filter((segmentNode): segmentNode is TextNode => segmentNode !== null)
+
+	if (!nodes.length) return
+
+	const [firstNode, ...restNodes] = nodes
+	if (!firstNode) return
+
+	node.replace(firstNode)
+	let currentNode: TextNode = firstNode
+	for (const segmentNode of restNodes) {
+		currentNode.insertAfter(segmentNode)
+		currentNode = segmentNode
+	}
+}
+
+const editor = useLexicalComposer()
+
+onMounted(() => {
+	const removeTransform = editor.registerNodeTransform(TextNode, varLabelTransform)
+
+	onUnmounted(() => {
+		removeTransform()
+	})
+})
+</script>
+
+<template />

+ 426 - 0
apps/web/src/components/PromptEditor/plugins/VarLaberPickerPlugin.vue

@@ -0,0 +1,426 @@
+<script setup lang="ts">
+import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { MenuOption } from 'lexical-vue'
+import { useLexicalComposer } from 'lexical-vue/LexicalComposer'
+import { TypeaheadMenuPlugin } from 'lexical-vue/LexicalTypeaheadMenuPlugin'
+import type { TriggerFn } from 'lexical-vue/LexicalTypeaheadMenuPlugin'
+import { $insertNodes, type LexicalEditor, type TextNode } from 'lexical'
+import { Icon } from '@repo/ui'
+import { VARIABLE_TYPE_OPTIONS } from '@/constant'
+import { nodeMap } from '@/nodes'
+import { $createVarLabelNode } from './VarLabel'
+import { getVariableTypeByValue, type VariableType } from '../utils'
+
+type NodeMapItem = {
+	icon?: string
+	iconColor?: string
+}
+
+const NODE_MAP = nodeMap as Record<string, NodeMapItem>
+
+export type PromptEditorMenuOption = {
+	key: string
+	label: string
+	value: string
+	className?: string
+	type?: VariableType
+	groupId?: string
+	groupName?: string
+	nodeType?: string
+	valueType?: string
+}
+
+class VarMenuOption extends MenuOption {
+	label: string
+	value: string
+	className?: string
+	type?: VariableType
+	groupId?: string
+	groupName?: string
+	nodeType?: string
+	valueType?: string
+
+	constructor(option: PromptEditorMenuOption, index: number) {
+		super(`${option.key || option.value}-${index}`)
+		this.label = option.label
+		this.value = option.value
+		this.className = option.className
+		this.type = option.type
+		this.groupId = option.groupId
+		this.groupName = option.groupName
+		this.nodeType = option.nodeType
+		this.valueType = option.valueType
+	}
+}
+
+type MenuSection = {
+	key: string
+	title: string
+	items: Array<{
+		index: number
+		option: VarMenuOption
+	}>
+}
+
+const props = withDefaults(
+	defineProps<{
+		triggerStrings?: string[]
+		options: PromptEditorMenuOption[]
+	}>(),
+	{
+		triggerStrings: () => ['/']
+	}
+)
+
+const editor = useLexicalComposer()
+const keyword = ref('')
+const isMenuOpen = ref(false)
+const suppressMenu = ref(false)
+const menuPanelRef = ref<HTMLElement | null>(null)
+
+const normalizedOptions = computed(() => {
+	return props.options.map((option, index) => new VarMenuOption(option, index))
+})
+
+const filteredOptions = computed(() => {
+	const kw = keyword.value.trim().toLowerCase()
+	if (!kw) return normalizedOptions.value
+
+	return normalizedOptions.value.filter((option) => {
+		const label = option.label.toLowerCase()
+		const value = option.value.toLowerCase()
+		const groupName = (option.groupName || '').toLowerCase()
+		return label.includes(kw) || value.includes(kw) || groupName.includes(kw)
+	})
+})
+
+const groupedSections = computed<MenuSection[]>(() => {
+	const map = new Map<string, MenuSection>()
+
+	filteredOptions.value.forEach((option, index) => {
+		const key = option.groupId || 'custom'
+		const title = option.groupName || 'Custom Variables'
+
+		if (!map.has(key)) {
+			map.set(key, { key, title, items: [] })
+		}
+
+		map.get(key)?.items.push({ index, option })
+	})
+
+	return [...map.values()]
+})
+
+const normalizedTriggers = computed(() => {
+	return props.triggerStrings
+		.map((trigger) => trigger.trim())
+		.filter((trigger) => trigger.length > 0)
+})
+
+const hasFreshTriggerAtTail = (text: string) => {
+	return normalizedTriggers.value.some((trigger) => text.endsWith(trigger))
+}
+
+const triggerMatch: TriggerFn = (text, _lexicalEditor: LexicalEditor) => {
+	if (suppressMenu.value) {
+		if (!hasFreshTriggerAtTail(text)) {
+			return null
+		}
+		suppressMenu.value = false
+	}
+
+	let latestMatch: ReturnType<TriggerFn> = null
+
+	for (const trigger of normalizedTriggers.value) {
+		const triggerIndex = text.lastIndexOf(trigger)
+		if (triggerIndex < 0) continue
+
+		const matchingString = text.slice(triggerIndex + trigger.length)
+		if (/\s/.test(matchingString)) continue
+
+		const match = {
+			leadOffset: triggerIndex,
+			matchingString,
+			replaceableString: `${trigger}${matchingString}`
+		}
+
+		if (!latestMatch || triggerIndex > latestMatch.leadOffset) {
+			latestMatch = match
+		}
+	}
+
+	return latestMatch
+}
+
+const normalizeTypeLabel = (type?: string) => {
+	if (!type) return ''
+	return VARIABLE_TYPE_OPTIONS.find((item) => item.value === type)?.label || type
+}
+
+const onQueryChange = (payload: string | null) => {
+	const nextKeyword = payload || ''
+	if (nextKeyword.length > 0) {
+		requestCloseMenu()
+		return
+	}
+
+	keyword.value = nextKeyword
+}
+
+const onOpen = () => {
+	isMenuOpen.value = true
+}
+
+const onClose = () => {
+	isMenuOpen.value = false
+	keyword.value = ''
+}
+
+const requestCloseMenu = () => {
+	suppressMenu.value = true
+	keyword.value = ''
+	editor.update(() => {
+		// Trigger plugin update cycle so menu closes immediately.
+	})
+}
+
+const resolveOptionClassName = (option: VarMenuOption) => {
+	if (option.className) return option.className
+	if (option.type) return `${option.type}-label`
+
+	const variableType = getVariableTypeByValue(option.value)
+	if (variableType) return `${variableType}-label`
+
+	return 'custom-label'
+}
+
+const onSelectOption = (payload: {
+	option: VarMenuOption
+	textNodeContainingQuery: TextNode | null
+	closeMenu: () => void
+	matchingString: string
+}) => {
+	const { option, textNodeContainingQuery, closeMenu } = payload
+
+	editor.update(() => {
+		const node = $createVarLabelNode(resolveOptionClassName(option), option.value)
+
+		if (textNodeContainingQuery) {
+			textNodeContainingQuery.replace(node)
+		} else {
+			$insertNodes([node])
+		}
+
+		closeMenu()
+	})
+}
+
+const onDocumentMouseDown = (event: MouseEvent) => {
+	if (!isMenuOpen.value) return
+
+	const target = event.target as Node | null
+	if (!target) return
+
+	const clickedInsideMenu = menuPanelRef.value?.contains(target) ?? false
+	if (clickedInsideMenu) return
+
+	const rootElement = editor.getRootElement()
+	const clickedInsideEditor = rootElement?.contains(target) ?? false
+	if (clickedInsideEditor) return
+
+	requestCloseMenu()
+	rootElement?.blur()
+}
+
+const onEditorKeyDown = (event: KeyboardEvent) => {
+	if (!isMenuOpen.value) return
+	if (event.key !== 'Escape') return
+
+	event.preventDefault()
+	event.stopPropagation()
+	requestCloseMenu()
+}
+
+let removeRootListener: (() => void) | null = null
+
+onMounted(() => {
+	document.addEventListener('mousedown', onDocumentMouseDown, true)
+
+	removeRootListener = editor.registerRootListener((nextRoot, prevRoot) => {
+		prevRoot?.removeEventListener('keydown', onEditorKeyDown, true)
+		nextRoot?.addEventListener('keydown', onEditorKeyDown, true)
+	})
+})
+
+onUnmounted(() => {
+	document.removeEventListener('mousedown', onDocumentMouseDown, true)
+	removeRootListener?.()
+})
+</script>
+
+<template>
+	<TypeaheadMenuPlugin
+		:options="filteredOptions"
+		:onOpen="onOpen"
+		:onClose="onClose"
+		:onQueryChange="onQueryChange"
+		:onSelectOption="onSelectOption"
+		:triggerFn="triggerMatch"
+		:ignoreEntityBoundary="true"
+		anchorClassName="z-[999999] translate-y-[6px]"
+	>
+		<template #default="{ anchorElementRef, itemProps }">
+			<Teleport v-if="anchorElementRef" :to="anchorElementRef">
+				<div ref="menuPanelRef" class="var-select__popover">
+					<div class="var-select__panel">
+						<el-input
+							:model-value="keyword"
+							size="small"
+							placeholder="搜索变量"
+							class="var-select__search"
+							readonly
+							tabindex="-1"
+							@mousedown.prevent
+						/>
+
+						<div class="var-select__list">
+							<div v-for="section in groupedSections" :key="section.key" class="var-select__group">
+								<div class="var-select__group-title">{{ section.title }}</div>
+								<div
+									v-for="item in section.items"
+									:key="item.option.key"
+									class="var-select__item"
+									:class="{ 'is-active': itemProps.selectedIndex === item.index }"
+									:ref="item.option.setRefElement"
+									@mouseenter="itemProps.setHighlightedIndex(item.index)"
+									@mousedown.prevent
+									@click="itemProps.selectOptionAndCleanUp(item.option)"
+								>
+									<span
+										class="var-select__item-prefix"
+										:class="item.option.groupId"
+										:style="{ background: NODE_MAP[item.option.nodeType || '']?.iconColor || '' }"
+									>
+										<span v-if="item.option.groupId === 'env'" class="text-6px">ENV</span>
+										<span v-else-if="item.option.groupId === 'sys'" class="text-6px">SYS</span>
+										<Icon
+											v-else-if="item.option.nodeType && NODE_MAP[item.option.nodeType || '']?.icon"
+											:icon="NODE_MAP[item.option.nodeType || '']?.icon || ''"
+											:size="16"
+										/>
+										<span v-else class="text-6px">VAR</span>
+									</span>
+									<span class="var-select__item-name" :title="item.option.label">
+										{{ item.option.label }}
+									</span>
+									<span class="var-select__item-type">
+										{{ normalizeTypeLabel(item.option.valueType) }}
+									</span>
+								</div>
+							</div>
+
+							<div v-if="!groupedSections.length" class="var-select__empty">暂无匹配变量</div>
+						</div>
+					</div>
+				</div>
+			</Teleport>
+		</template>
+	</TypeaheadMenuPlugin>
+</template>
+
+<style scoped lang="less">
+.var-select__popover {
+	min-width: 320px;
+	max-width: 420px;
+	padding: 8px;
+	border: 1px solid #dcdfe6;
+	border-radius: 8px;
+	background: #fff;
+	box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
+}
+
+.var-select__panel {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.var-select__search {
+	:deep(.el-input__wrapper) {
+		box-shadow: none;
+		border-radius: 8px;
+		background-color: #f5f5f5;
+	}
+}
+
+.var-select__list {
+	max-height: 260px;
+	overflow-y: auto;
+}
+
+.var-select__group + .var-select__group {
+	margin-top: 8px;
+	border-top: 1px solid #f2f2f2;
+	padding-top: 8px;
+}
+
+.var-select__group-title {
+	font-size: 12px;
+	color: #999;
+	margin-bottom: 4px;
+}
+
+.var-select__item {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+	padding: 4px 6px;
+	border-radius: 6px;
+	cursor: pointer;
+	font-size: 12px;
+	color: #333;
+
+	&.is-active,
+	&:hover {
+		background-color: #f5f7ff;
+	}
+}
+
+.var-select__item-prefix {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: 20px;
+	height: 20px;
+	border-radius: 6px;
+	color: #fff;
+	flex-shrink: 0;
+
+	&.env {
+		background: #6366f1;
+	}
+
+	&.sys {
+		background: #f97316;
+	}
+}
+
+.var-select__item-name {
+	flex: 1;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.var-select__item-type {
+	color: #999;
+	flex-shrink: 0;
+}
+
+.var-select__empty {
+	padding: 12px 4px;
+	font-size: 12px;
+	color: #999;
+	text-align: center;
+}
+</style>

+ 186 - 0
apps/web/src/components/PromptEditor/utils.ts

@@ -0,0 +1,186 @@
+import { NodeVariableRegex, SystemVariableRegex, EnvVariableRegex } from '@/utils/varReg'
+
+export type VariableType = 'node' | 'sys' | 'env'
+
+export function textToEditorState(text: string) {
+	const paragraph = text && typeof text === 'string' ? text.split('\n') : ['']
+
+	return JSON.stringify({
+		root: {
+			children: paragraph.map((p) => {
+				const segments = splitTextByVariableSegments(p)
+				const children = segments
+					.map((segment) => {
+						if (segment.kind === 'text') {
+							if (!segment.text.length) return null
+							return {
+								detail: 0,
+								format: 0,
+								mode: 'normal',
+								style: '',
+								text: segment.text,
+								type: 'text',
+								version: 1
+							}
+						}
+						return {
+							className: `${segment.varType}-label`,
+							detail: 0,
+							format: 0,
+							mode: 'token',
+							style: '',
+							text: segment.text,
+							type: 'var-label',
+							version: 1
+						}
+					})
+					.filter((item) => item !== null)
+
+				return {
+					children: children.length
+						? children
+						: [
+								{
+									detail: 0,
+									format: 0,
+									mode: 'normal',
+									style: '',
+									text: '',
+									type: 'text',
+									version: 1
+								}
+							],
+					direction: 'ltr',
+					format: '',
+					indent: 0,
+					type: 'paragraph',
+					version: 1
+				}
+			}),
+			direction: 'ltr',
+			format: '',
+			indent: 0,
+			type: 'root',
+			version: 1
+		}
+	})
+}
+
+export type VariableMatch = {
+	start: number
+	end: number
+	raw: string
+	name: string
+	type: VariableType
+}
+
+export type TextSegment = {
+	kind: 'text'
+	text: string
+}
+
+export type VariableSegment = {
+	kind: 'variable'
+	text: string
+	varType: VariableType
+	name: string
+}
+
+export type PromptEditorSegment = TextSegment | VariableSegment
+
+function collectMatches(text: string, reg: RegExp, type: VariableType): VariableMatch[] {
+	const globalReg = new RegExp(reg.source, 'g')
+	const list: VariableMatch[] = []
+
+	for (const match of text.matchAll(globalReg)) {
+		const raw = match[0] ?? ''
+		const name = match[1] ?? ''
+		const start = match.index ?? -1
+		const end = start + raw.length
+
+		if (start < 0 || !raw) continue
+
+		list.push({
+			start,
+			end,
+			raw,
+			name,
+			type
+		})
+	}
+
+	return list
+}
+
+export function getVariableMatches(text: string): VariableMatch[] {
+	const candidates = [
+		...collectMatches(text, NodeVariableRegex, 'node'),
+		...collectMatches(text, SystemVariableRegex, 'sys'),
+		...collectMatches(text, EnvVariableRegex, 'env')
+	]
+
+	return candidates.sort((a, b) => a.start - b.start)
+}
+
+export function getMatch(text: string): VariableMatch | null {
+	const candidates = getVariableMatches(text)
+
+	if (!candidates.length) return null
+
+	return candidates[0] ?? null
+}
+
+export function splitTextByVariableSegments(text: string): PromptEditorSegment[] {
+	if (!text.length) {
+		return [{ kind: 'text', text: '' }]
+	}
+
+	const matches = getVariableMatches(text)
+	if (!matches.length) {
+		return [{ kind: 'text', text }]
+	}
+
+	const result: PromptEditorSegment[] = []
+	let cursor = 0
+
+	for (const match of matches) {
+		if (match.start < cursor) continue
+
+		if (match.start > cursor) {
+			result.push({
+				kind: 'text',
+				text: text.slice(cursor, match.start)
+			})
+		}
+
+		result.push({
+			kind: 'variable',
+			text: match.raw,
+			varType: match.type,
+			name: match.name
+		})
+
+		cursor = match.end
+	}
+
+	if (cursor < text.length) {
+		result.push({
+			kind: 'text',
+			text: text.slice(cursor)
+		})
+	}
+
+	return result.length ? result : [{ kind: 'text', text }]
+}
+
+function testWholeVariable(regex: RegExp, value: string) {
+	const exactReg = new RegExp(`^${regex.source}$`)
+	return exactReg.test(value)
+}
+
+export function getVariableTypeByValue(value: string): VariableType | null {
+	if (testWholeVariable(NodeVariableRegex, value)) return 'node'
+	if (testWholeVariable(SystemVariableRegex, value)) return 'sys'
+	if (testWholeVariable(EnvVariableRegex, value)) return 'env'
+	return null
+}

+ 93 - 0
apps/web/src/components/VarLabel/index.vue

@@ -0,0 +1,93 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import { nodeMap } from '@/nodes'
+
+const props = defineProps<{
+	label: string
+	nodeVars: any[]
+}>()
+
+const valueInfo = computed(() => {
+	// 根据#{xxx.var}解析变量
+	// 解析格式 #{env.xxx} 或 #{nodeId.xxx}
+	if (!props.label?.startsWith('#{') || !props.label?.endsWith('}')) {
+		return {
+			type: 'env',
+			name: props.label || '',
+			value: props.label || ''
+		}
+	}
+
+	const expr = props.label.slice(2, -1) // 去掉 #{ 和 }
+	const [prefix, ...rest] = expr.split('.')
+	const varName = rest.join('.')
+
+	if (!prefix || !varName) {
+		return {
+			type: 'env',
+			name: props.label || '',
+			value: props.label || ''
+		}
+	}
+
+	if (prefix === 'env') {
+		// 环境变量
+		return {
+			type: 'env',
+			name: varName,
+			value: varName
+		}
+	} else if (prefix.startsWith('sys')) {
+		// 系统变量
+		return {
+			type: 'system',
+			name: props.label,
+			value: varName
+		}
+	} else {
+		// 节点变量,需要解析节点类型名称
+		if (props.nodeVars && Array.isArray(props.nodeVars)) {
+			const node = props.nodeVars.find((item) => item.type === 'output' && item.id === prefix)
+			return {
+				type: 'node',
+				nodeType: node?.nodeType || '',
+				nodeName: node?.name || prefix,
+				name: varName,
+				value: varName
+			}
+		} else {
+			return {
+				type: 'node',
+				nodeType: '',
+				nodeName: prefix,
+				name: varName,
+				value: varName
+			}
+		}
+	}
+})
+</script>
+
+<template>
+	<div class="var-label">
+		<div v-if="valueInfo.type === 'env'" class="flex gap-1 items-center truncate">
+			<span class="var-select__item-prefix env text-6px">
+				<span>ENV</span>
+			</span>
+			<span class="text-gray-600">{{ valueInfo.value }}</span>
+		</div>
+		<div v-else class="truncate">
+			<span
+				class="var-select__item-prefix env text-10px"
+				:style="{ background: nodeMap[valueInfo?.nodeType ?? '']?.iconColor ?? '' }"
+			>
+				<Icon :icon="nodeMap[valueInfo?.nodeType!]?.icon!" :size="18" />
+			</span>
+			<span>{{ valueInfo.nodeName }}</span>
+			<span class="mx-2px text-gray-400">/</span>
+			<span class="text-gray-600">{{ valueInfo.value }}</span>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped></style>

+ 60 - 1
apps/web/src/constant/index.ts

@@ -10,7 +10,8 @@ export const VARIABLE_TYPE_OPTIONS = [
 	{ label: 'Array[String]', value: 'array[string]' },
 	{ label: 'Array[Number]', value: 'array[number]' },
 	{ label: 'Array[Boolean]', value: 'array[boolean]' },
-	{ label: 'Array[Object]', value: 'array[object]' }
+	{ label: 'Array[Object]', value: 'array[object]' },
+	{ label: 'Array[File]', value: 'array[file]' }
 ]
 
 /**
@@ -20,3 +21,61 @@ export const VARIABLE_VALUE_OPTIONS = [
 	{ label: 'Constant', value: 'constant' },
 	{ label: 'Variable', value: 'variable' }
 ]
+
+/**
+ * 变量类型操作符
+ */
+export const VARIABLE_TYPE_OPERATORS = {
+	number: [
+		{ label: '=', value: '=' },
+		{ label: '≠', value: '!=' },
+		{ label: '>', value: '>' },
+		{ label: '<', value: '<' },
+		{ label: '≥', value: '≥' },
+		{ label: '≤', value: '≤' },
+		{ label: '为空', value: 'empty' },
+		{ label: '不为空', value: 'not_empty' }
+	],
+	string: [
+		{ label: '包含', value: 'contains' },
+		{ label: '不包含', value: 'not_contains' },
+		{ label: '开头是', value: 'start_with' },
+		{ label: '结尾是', value: 'end_with' },
+		{ label: '是', value: 'is' },
+		{ label: '不是', value: 'is_not' },
+		{ label: '为空', value: 'empty' },
+		{ label: '不为空', value: 'not_empty' }
+	],
+	boolean: [
+		{ label: '是', value: 'is' },
+		{ label: '不是', value: 'is_not' }
+	],
+	object: [
+		{ label: '是', value: 'is' },
+		{ label: '不是', value: 'is_not' },
+		{ label: '为空', value: 'empty' },
+		{ label: '不为空', value: 'not_empty' }
+	],
+	'array[string]': [
+		{ label: '包含', value: 'contains' },
+		{ label: '不包含', value: 'not_contains' },
+		{ label: '为空', value: 'empty' },
+		{ label: '不为空', value: 'not_empty' }
+	],
+	'array[number]': [
+		{ label: '包含', value: 'contains' },
+		{ label: '不包含', value: 'not_contains' },
+		{ label: '为空', value: 'empty' },
+		{ label: '不为空', value: 'not_empty' }
+	],
+	'array[boolean]': [
+		{ label: '包含', value: 'contains' },
+		{ label: '不包含', value: 'not_contains' },
+		{ label: '为空', value: 'empty' },
+		{ label: '不为空', value: 'not_empty' }
+	],
+	'array[object]': [
+		{ label: '为空', value: 'empty' },
+		{ label: '不为空', value: 'not_empty' }
+	]
+}

+ 18 - 30
apps/web/src/features/setter/index.vue

@@ -6,14 +6,16 @@
  * @Describe: file describe
 -->
 <script lang="ts" setup>
-import { computed, provide } from 'vue'
+import { computed, provide, watch, ref } from 'vue'
 import { Icon, Input } from '@repo/ui'
 import { useDebounceFn } from '@vueuse/core'
 import NodeLog from './NodeLog.vue'
+import { agent } from '@repo/api-service'
 
 import { nodeMap } from '@/nodes'
 
 import type { IWorkflowNode, IWorkflow } from '@repo/workflow'
+import type { NodeVar } from '@/types/var'
 
 interface Props {
 	id: string
@@ -78,39 +80,24 @@ const remark = computed({
 	}
 })
 
-const getBeforeNodeIds = (id: string): string[] => {
-	const { edges } = props.workflow
-	// 找到当前节点的所有上一级,以及上一级全部的上一级的节点
-	const before = edges.filter((edge) => edge.target === id).map((edge) => edge.source)
-	if (before.length > 0) {
-		return before.concat(before.flatMap((id) => getBeforeNodeIds(id)))
-	}
-	return before
-}
-// 获取当前节点的所有上一级输出变量及环境变量
-const nodeVars = computed(() => {
-	const { nodes, env_variables = [] } = props.workflow
-
-	const beforeNodeIds = getBeforeNodeIds(props.id)
-	const beforeNodes = nodes.filter((node) => beforeNodeIds.includes(node.id))
-
-	return [
-		...beforeNodes.map((node) => ({
-			type: 'output',
-			id: node.id,
-			list: node.data.outputs,
-			name: node.name,
-			nodeType: node.nodeType
-		})),
-		{
-			type: 'env',
-			list: env_variables
+const nodeVars = ref<NodeVar[]>([])
+
+watch(
+	() => props.id,
+	async (id) => {
+		if (id) {
+			const response = await agent.postAgentGetPrevNodeOutVariableList({
+				node_id: id,
+				varTypeList: []
+			})
+			nodeVars.value = (response.result as NodeVar[]) || []
 		}
-	]
-})
+	}
+)
 
 provide('nodeVars', nodeVars)
 </script>
+
 <template>
 	<div class="setter">
 		<div class="drawer shadow-2xl" :class="{ 'drawer--open': props.visible && setter }">
@@ -143,6 +130,7 @@ provide('nodeVars', nodeVars)
 						<component
 							:is="setter"
 							:key="node?.id"
+							:id="node?.id"
 							:data="node?.data"
 							@update="onUpdate"
 						></component>

+ 1 - 1
apps/web/src/features/toolbar/AgentEnvDialog.vue

@@ -157,7 +157,7 @@ const editFormRules = {
 				if (!value) {
 					callback(new Error('请输入变量名'))
 				}
-				if (envList.value.some((item) => item.name === value)) {
+				if (envList.value.some((item) => item.name === value) && editingIndex.value === -1) {
 					callback(new Error('变量名不能重复'))
 				}
 				callback()

+ 10 - 1
apps/web/src/nodes/Interface.ts

@@ -76,7 +76,7 @@ export interface ConfigSchema {
 	renderItem?: (renderCallbackParams: RenderCallbackParams) => Element
 }
 
-type EndpointType = string | { type: string; label: string }
+type EndpointType = string | { type: string; label: string; id: string }
 
 export interface INodeType {
 	/**
@@ -160,6 +160,10 @@ export interface INodeType {
 	 * 配置校验
 	 */
 	validate?: (data: any) => boolean | string | Promise<boolean | string>
+	/**
+	 * 节点副标题
+	 */
+	getSubtitle?: (data: any) => string | Promise<string> | undefined
 	// 其他配置
 	[key: string]: any
 }
@@ -285,6 +289,11 @@ export interface INodeDataBaseSchema {
 	 * 输出变量列表
 	 */
 	outputs: NodeVariable[]
+
+	/**
+	 * 节点id
+	 */
+	id?: string
 }
 
 /**

+ 20 - 77
apps/web/src/nodes/_base/VarSelect.vue

@@ -26,7 +26,7 @@
 								</span>
 								<span class="text-gray-600">{{ valueInfo.value }}</span>
 							</div>
-							<div v-else class="truncate">
+							<div v-else class="truncate" :title="valueInfo.nodeName + ' / ' + valueInfo.value">
 								<span
 									class="var-select__item-prefix env text-10px"
 									:style="{ background: nodeMap[valueInfo?.nodeType ?? '']?.iconColor ?? '' }"
@@ -54,27 +54,28 @@
 				<div class="var-select__list">
 					<!-- 变量列表 -->
 					<div v-for="group in filteredVars" class="var-select__group">
-						<div class="var-select__group-title">{{ group.label }}</div>
+						<div class="var-select__group-title">{{ group.name }}</div>
 						<div
-							v-for="item in group.list"
-							:key="`${group.label}-${item.name}`"
+							v-for="item in group.variableList"
+							:key="item.expression"
 							class="var-select__item"
 							@click="
 								handleSelect({
-									value: `${group.id}.${item.name}`,
+									value: item.expression,
 									type: item.type
 								})
 							"
 						>
 							<span
 								class="var-select__item-prefix env text-10px"
-								:style="{ background: nodeMap[group?.nodeType ?? '']?.iconColor ?? '' }"
+								:style="{ background: nodeMap[group?.type ?? '']?.iconColor ?? '' }"
 							>
 								<span v-if="group.id === 'env'" class="text-6px">ENV</span>
-								<Icon v-if="group?.nodeType" :icon="nodeMap[group?.nodeType]?.icon!" :size="18" />
+								<span v-if="group.id === 'sys'" class="text-6px">SYS</span>
+								<Icon v-if="group?.type" :icon="nodeMap[group?.type]?.icon!" :size="18" />
 							</span>
 							<span class="var-select__item-name" :title="item.name">{{ item.name }}</span>
-							<span class="var-select__item-type">{{ item.typeLabel }}</span>
+							<span class="var-select__item-type">{{ normalizeTypeLabel(item.type) }}</span>
 						</div>
 					</div>
 
@@ -91,28 +92,19 @@
 import { computed, ref, watch, inject, type Ref } from 'vue'
 import { Icon } from '@repo/ui'
 import { nodeMap } from '@/nodes'
+import { VARIABLE_TYPE_OPTIONS } from '@/constant'
 
-type EnvVarType = 'string' | 'number' | 'boolean' | 'object' | 'array' | string
-
-interface SystemVariable {
-	name: string
-	type?: EnvVarType
-}
+import type { NodeVar, VarType } from '@/types/var'
 
 interface Props {
 	/**
 	 * 选中的变量,格式 #{xxx.var}
 	 */
 	modelValue: string
-	/**
-	 * 系统变量列表,不传则使用默认内置列表
-	 */
-	systemVars?: SystemVariable[]
 }
 
 const props = withDefaults(defineProps<Props>(), {
-	modelValue: '',
-	systemVars: () => []
+	modelValue: ''
 })
 
 const emit = defineEmits<{
@@ -120,10 +112,7 @@ const emit = defineEmits<{
 	(e: 'change', value: { value: string; type: string }): void
 }>()
 
-const nodeVars =
-	inject<
-		Ref<{ type: 'env' | 'output'; list: any[]; name: string; nodeType: string; id: string }[]>
-	>('nodeVars')
+const nodeVars = inject<Ref<NodeVar[]>>('nodeVars')
 
 const visible = ref(false)
 const keyword = ref('')
@@ -192,10 +181,10 @@ const valueInfo = computed(() => {
 	} else {
 		// 节点变量,需要解析节点类型名称
 		if (nodeVars && Array.isArray(nodeVars.value)) {
-			const node = nodeVars.value.find((item) => item.type === 'output' && item.id === prefix)
+			const node = nodeVars.value.find((item) => item.id === prefix)
 			return {
 				type: 'node',
-				nodeType: node?.nodeType || '',
+				nodeType: node?.type || '',
 				nodeName: node?.name || prefix,
 				name: varName,
 				value: varName
@@ -212,15 +201,9 @@ const valueInfo = computed(() => {
 	}
 })
 
-const normalizeTypeLabel = (type?: EnvVarType) => {
+const normalizeTypeLabel = (type?: VarType) => {
 	if (!type) return ''
-	const t = String(type).toLowerCase()
-	if (t === 'string') return 'String'
-	if (t === 'number') return 'Number'
-	if (t === 'boolean') return 'Boolean'
-	if (t === 'object') return 'Object'
-	if (t === 'array') return 'Array'
-	return type as string
+	return VARIABLE_TYPE_OPTIONS.find((item) => item.value === type)?.label || ''
 }
 
 /**
@@ -229,51 +212,11 @@ const normalizeTypeLabel = (type?: EnvVarType) => {
 const filteredVars = computed(() => {
 	const kw = keyword.value.trim().toLowerCase()
 
-	let list: {
-		label: string
-		id: string
-		nodeType?: string
-		list: {
-			name: any
-			type: any
-			typeLabel: string
-		}[]
-	}[] = []
-
-	const nodeOutputs = nodeVars?.value?.filter(
-		(item) => item.type === 'output' && item.list.find((i) => i.name.includes(kw))
+	const options = nodeVars?.value?.filter((item) =>
+		item.variableList.find((i) => i.name.includes(kw))
 	)
 
-	nodeOutputs?.forEach((item) => {
-		list.push({
-			label: item.name,
-			nodeType: item.nodeType,
-			id: item.id,
-			list: item.list
-				.filter((i) => i.name.includes(kw))
-				.map((i) => ({
-					name: i.name,
-					type: i.type,
-					typeLabel: normalizeTypeLabel(i.type)
-				}))
-		})
-	})
-
-	const envVars = nodeVars?.value?.find((item) => item.type === 'env')?.list
-
-	if (envVars) {
-		list.push({
-			label: 'ENVIRONMENT',
-			id: 'env',
-			list: envVars.map((item) => ({
-				name: item.name,
-				type: item.type,
-				typeLabel: normalizeTypeLabel(item.type)
-			}))
-		})
-	}
-
-	return list
+	return options || []
 })
 
 const handleSelect = (variable: { value: string; type: string }) => {

+ 28 - 28
apps/web/src/nodes/_base/condition/ConditionItem.vue

@@ -1,12 +1,17 @@
 <template>
 	<div
-		class="bg-#f1f3f6 rounded-4px border border-0.5px border-solid border-gray-200 overflow-hidden"
+		class="rounded-4px border border-0.5px border-solid border-gray-200 overflow-hidden"
 		v-bind="$attrs"
 	>
 		<!-- 条件行 -->
-		<div class="flex items-center gap-1">
+		<div class="flex items-center">
 			<!-- 左侧变量选择 -->
-			<VarSelect v-model="modelValue.left_value" class="flex-1" placeholder="{x} 设置变量值" />
+			<VarSelect
+				v-model="modelValue.left_value"
+				class="flex-1 max-w-[160px]"
+				placeholder="{x} 设置变量值"
+			/>
+			<el-divider direction="vertical" />
 			<!-- 运算符选择 -->
 			<el-select
 				v-model="modelValue.comparison_operator"
@@ -14,6 +19,7 @@
 				style="width: 80px"
 			></el-select>
 		</div>
+		<el-divider class="my-0!" />
 		<div>
 			<Input
 				v-if="modelValue.varType !== 'number'"
@@ -41,35 +47,12 @@
 import { ref, computed } from 'vue'
 import { Input } from '@repo/ui'
 import VarSelect from '../VarSelect.vue'
+import { VARIABLE_TYPE_OPERATORS } from '@/constant'
 
 import type { ConditionType } from '../../Interface'
 
 const modelValue = defineModel<ConditionType>('modelValue', { required: true })
 
-const operatorsMap = {
-	number: [
-		{ label: '=', value: '=' },
-		{ label: '≠', value: '!=' },
-		{ label: '>', value: '>' },
-		{ label: '<', value: '<' },
-		{ label: '≥', value: '≥' },
-		{ label: '≤', value: '≤' },
-		{ label: '为空', value: 'empty' },
-		{ label: '不为空', value: 'not_empty' }
-	],
-	string: [
-		{ label: '包含', value: 'contains' },
-		{ label: '不包含', value: 'not_contains' },
-		{ label: '开头是', value: 'start_with' },
-		{ label: '结尾是', value: 'end_with' },
-		{ label: '是', value: 'is' },
-		{ label: '不是', value: 'is_not' },
-		{ label: '为空', value: 'empty' },
-		{ label: '不为空', value: 'not_empty' },
-		{ label: '全部满足', value: 'all_of' }
-	]
-}
-
 const numberType = ref<string>('constant')
 
 const numberValOptions = [
@@ -79,6 +62,23 @@ const numberValOptions = [
 
 const operators = computed(() => {
 	const type = modelValue.value.varType ?? 'string'
-	return operatorsMap?.[type as keyof typeof operatorsMap] ?? []
+	return VARIABLE_TYPE_OPERATORS?.[type as keyof typeof VARIABLE_TYPE_OPERATORS] ?? []
 })
 </script>
+
+<style lang="less" scoped>
+:deep(.el-input-tag__wrapper) {
+	box-shadow: none;
+	&:hover,
+	&.is-focus {
+		box-shadow: 0 0 0 1px var(--el-input-focus-border-color) inset;
+	}
+}
+:deep(.el-select__wrapper) {
+	box-shadow: none;
+	&:hover,
+	&.is-focus {
+		box-shadow: 0 0 0 1px var(--el-input-focus-border-color) inset;
+	}
+}
+</style>

+ 1 - 1
apps/web/src/nodes/_base/condition/index.vue

@@ -51,7 +51,7 @@ const addCondition = () => {
 	modelValue.value.push({
 		left_value: '',
 		right_value: '',
-		comparison_operator: '=',
+		comparison_operator: 'contains',
 		varType: 'string'
 	})
 }

+ 16 - 2
apps/web/src/nodes/src/condition/index.ts

@@ -26,8 +26,22 @@ export const conditionNode: INodeType = {
 	icon: 'lucide:trending-up-down',
 	iconColor: '#06aed4',
 	inputs: [NodeConnectionTypes.main],
-	outputs: () => {
-		return [NodeConnectionTypes.main]
+	outputs: (data: ConditionData) => {
+		const cases = data?.cases || []
+		const ports = cases.map((condition, index) => {
+			return {
+				id: condition.id,
+				label: '条件_' + (index + 1),
+				type: 'port'
+			}
+		})
+
+		ports.push({
+			id: data?.id || 'source',
+			label: 'ELSE',
+			type: 'port'
+		})
+		return ports
 	},
 	// 业务数据
 	schema: {

+ 34 - 31
apps/web/src/nodes/src/condition/setter.vue

@@ -1,15 +1,18 @@
 <template>
 	<div class="w-full p-4 pt-0 box-border flex flex-col gap-2">
 		<div
-			v-for="(caseItem, index) in cases"
+			v-for="(caseItem, index) in formData.cases"
 			:key="caseItem.id"
 			class="border border-b-0.5px border-b-solid border-gray-200 relative"
-			:style="{ paddingLeft: cases[index]!.conditions?.length < 2 ? '60px' : '0' }"
+			:style="{ paddingLeft: formData.cases[index]!.conditions?.length < 2 ? '60px' : '0' }"
 		>
-			<div class="absolute text-16px font-bold top-0 left-0">{{ index === 0 ? 'IF' : 'ELIF' }}</div>
+			<div class="absolute top-0 left-0">
+				<div class="text-12px text-gray-500">条件_{{ index + 1 }}</div>
+				<div class="text-16px font-bold">{{ index === 0 ? 'IF' : 'ELIF' }}</div>
+			</div>
 			<Condition
-				:conditions="cases[index]!.conditions"
-				:operator="cases[index]!.logical_operator"
+				:conditions="formData.cases[index]!.conditions"
+				:operator="formData.cases[index]!.logical_operator"
 				@update:conditions="onUpdateConditions(index, $event)"
 				@update:operator="onUpdateOperator(index, $event)"
 			/>
@@ -29,7 +32,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, ref, watch } from 'vue'
 import Condition from '../../_base/condition/index.vue'
 import { Icon } from '@repo/ui'
 
@@ -42,46 +45,46 @@ interface Emits {
 
 const props = defineProps<{
 	data: ConditionData
+	id: string
 }>()
 
 const emit = defineEmits<Emits>()
 
-const cases = computed<ConditionData['cases']>(() => props.data.cases)
+const formData = ref<ConditionData>(props.data)
+const cases = computed<ConditionData['cases']>(() => formData.value.cases)
+
+watch(
+	formData.value,
+	(newVal) => {
+		emit('update', newVal)
+	},
+	{ deep: true }
+)
+
+const getId = () => {
+	const ids = cases.value.map((caseItem) => caseItem.id.split('_')[1]).map(Number)
+	const maxId = Math.max(...ids, 0)
+	return props.id + '_' + (maxId + 1)
+}
 
 const addCase = () => {
-	emit('update', {
-		...props.data,
-		cases: [
-			...cases.value,
-			{
-				id: '',
-				conditions: [],
-				logical_operator: 'and'
-			}
-		]
+	const id = getId()
+	formData.value.cases.push({
+		id,
+		conditions: [],
+		logical_operator: 'and'
 	})
 }
 
 const removeCase = (index: number) => {
-	emit('update', {
-		...props.data,
-		cases: cases.value.filter((_, i) => i !== index)
-	})
+	formData.value.cases.splice(index, 1)
 }
 
 const onUpdateConditions = (index: number, conditions: ConditionType[]) => {
-	emit('update', {
-		...props.data,
-		cases: cases.value.map((caseItem, i) => (i === index ? { ...caseItem, conditions } : caseItem))
-	})
+	formData.value.cases[index]!.conditions = conditions
 }
 
 const onUpdateOperator = (index: number, operator: 'and' | 'or') => {
-	emit('update', {
-		...props.data,
-		cases: cases.value.map((caseItem, i) =>
-			i === index ? { ...caseItem, logical_operator: operator } : caseItem
-		)
-	})
+	formData.value.cases[index]!.logical_operator = operator
 }
 </script>

+ 3 - 0
apps/web/src/nodes/src/http/index.ts

@@ -56,6 +56,9 @@ export const httpNode: INodeType = {
 	validate: (data: HttpRequestData) => {
 		return !!data?.url ? false : '请填写URL'
 	},
+	getSubtitle: (data: HttpRequestData) => {
+		return data?.url ? `${data.method} ${data.url}` : ''
+	},
 	// 业务数据
 	schema: {
 		appAgentId: '',

+ 1 - 1
apps/web/src/nodes/src/http/setter.vue

@@ -164,7 +164,7 @@ const handleSaveAuthorization = () => {
 						placeholder="请选择"
 					>
 					</el-select>
-					<el-input class="flex-1" v-model="formData.url" placeholder="URL..."></el-input>
+					<VarInput class="flex-1" v-model="formData.url" placeholder="URL..."></VarInput>
 				</div>
 			</el-form-item>
 

+ 12 - 12
apps/web/src/router/index.ts

@@ -1,4 +1,4 @@
-import { createRouter, createWebHistory } from 'vue-router'
+import { createRouter, createWebHashHistory } from 'vue-router'
 import NProgress from 'nprogress'
 import 'nprogress/nprogress.css'
 
@@ -93,22 +93,22 @@ const routes = [
 ]
 
 const router = createRouter({
-	history: createWebHistory(),
+	history: createWebHashHistory(),
 	routes
 })
 
 router.beforeEach((to, from, next) => {
 	NProgress.start()
-	if (!('enterpriseCode' in to.query) && 'enterpriseCode' in from.query) {
-		next({
-			...to,
-			query: {
-				...to.query,
-				enterpriseCode: from.query.enterpriseCode
-			}
-		})
-		return
-	}
+	// if (!('enterpriseCode' in to.query) && 'enterpriseCode' in from.query) {
+	// 	next({
+	// 		...to,
+	// 		query: {
+	// 			...to.query,
+	// 			enterpriseCode: from.query.enterpriseCode
+	// 		}
+	// 	})
+	// 	return
+	// }
 	next()
 })
 

+ 22 - 0
apps/web/src/types/var.d.ts

@@ -0,0 +1,22 @@
+export type VarType =
+	| 'string'
+	| 'number'
+	| 'boolean'
+	| 'object'
+	| 'array[string]'
+	| 'array[number]'
+	| 'array[boolean]'
+	| 'array[object]'
+
+export interface NodeVarItem {
+	expression: string
+	name: string
+	type: VarType
+}
+
+export interface NodeVar {
+	id: string
+	name: string
+	type?: string
+	variableList: NodeVarItem[]
+}

+ 2 - 3
apps/web/src/utils/uuid.ts

@@ -2,8 +2,7 @@ import { tools } from '@repo/api-service'
 
 export const getUUID = async () => {
 	const res = await tools.postOpenapiDoBatchGenerateUuid({
-		body: {
-			count: 1
-		}
+		count: 5
 	})
+	return res.result
 }

+ 18 - 0
apps/web/src/utils/varReg.ts

@@ -0,0 +1,18 @@
+/**
+ * 节点变量正则
+ * 如:#{1b134736-e19f-449d-8e26-9adfec64773e.var_1}
+ */
+export const NodeVariableRegex =
+	/#\{([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\.[a-zA-Z_][a-zA-Z0-9_]*)\}/g
+
+/**
+ * 系统变量正则
+ * 如:#{style.variableName}
+ */
+export const SystemVariableRegex = /#\{sys\.([a-zA-Z_][a-zA-Z0-9_]*)\}/g
+
+/**
+ * 环境变量正则
+ * 如:#{env.variableName}
+ */
+export const EnvVariableRegex = /#\{env\.([a-zA-Z_][a-zA-Z0-9_]*)\}/g

+ 36 - 10
apps/web/src/views/Dashboard.vue

@@ -19,7 +19,7 @@
 					<SvgIcon name="workflow" size="24" />
 				</div>
 				<div class="stat-info">
-					<div class="stat-value">{{ workflows.length }}</div>
+					<div class="stat-value">{{ recentWorkflows.length }}</div>
 					<div class="stat-label">工作流总数</div>
 				</div>
 				<div class="stat-trend up">↑ 12%</div>
@@ -100,11 +100,16 @@
 					<el-button text @click="$router.push('/orchestration')">查看全部 →</el-button>
 				</div>
 				<div class="workflow-list">
-					<div class="workflow-item" v-for="item in recentWorkflows" :key="item.id">
-						<div class="workflow-icon" @click="toEditor(item.id)">
+					<div
+						class="workflow-item"
+						v-for="item in recentWorkflows"
+						:key="item.id"
+						@click="toEditor(item.id)"
+					>
+						<div class="workflow-icon">
 							<SvgIcon name="workflow" size="20" />
 						</div>
-						<div class="workflow-main" @click="toEditor(item.id)">
+						<div class="workflow-main">
 							<div class="workflow-title">{{ item.title }}</div>
 							<div class="workflow-meta">{{ item.created }}</div>
 						</div>
@@ -142,7 +147,12 @@
 				<el-button text @click="templateModalVisible = true">查看更多 →</el-button>
 			</div>
 			<div class="templates-grid">
-				<div class="template-card" v-for="item in templates" :key="item.id" @click="goToTemplate(item.id)">
+				<div
+					class="template-card"
+					v-for="item in templates"
+					:key="item.id"
+					@click="goToTemplate(item.id)"
+				>
 					<div class="template-icon">
 						<SvgIcon :name="item.icon" size="32" />
 					</div>
@@ -178,6 +188,8 @@ import SvgIcon from '@/components/SvgIcon/index.vue'
 import TemplateModal from '@/components/TemplateModal/index.vue'
 import CreateWorkflowModal from '@/features/createModal/index.vue'
 
+import { agent } from '@repo/api-service'
+
 const $router = useRouter()
 
 // 当前日期
@@ -199,7 +211,9 @@ const createWorkflow = () => {
 
 // 前往编辑器
 const toEditor = (id: string) => {
-	$router.push(`/workflow/${id}`)
+	$router.push({
+		path: `/workflow/${id}`
+	})
 }
 
 // 使用模板,打开弹窗
@@ -240,7 +254,7 @@ const deleteWorkflow = async (id: string) => {
 		localStorage.setItem(`workflow-map`, JSON.stringify(projectMap))
 
 		// 更新列表
-		workflows.value = workflows.value.filter((item) => item.id !== id)
+		recentWorkflows.value = recentWorkflows.value.filter((item) => item.id !== id)
 
 		ElMessage.success('删除成功')
 	} catch {
@@ -250,7 +264,7 @@ const deleteWorkflow = async (id: string) => {
 
 // 从本地存储加载工作流
 const projectMap = JSON.parse(localStorage.getItem(`workflow-map`) || '{}')
-const workflows = ref([
+const recentWorkflows = ref([
 	...Object.entries(projectMap)
 		.map(([_id, workflow]) => workflow)
 		.map((item: any) => ({
@@ -261,8 +275,20 @@ const workflows = ref([
 		}))
 ])
 
-// 最近工作流(取前 6 个)
-const recentWorkflows = computed(() => workflows.value.slice(0, 6))
+agent
+	.postAgentGetAgentList({
+		pageIndex: 1
+	})
+	.then((res) => {
+		if (res.result?.model?.length) {
+			recentWorkflows.value = res.result.model.map((item: any) => ({
+				...item,
+				id: item.id,
+				title: item.name,
+				created: item.created || '最近'
+			}))
+		}
+	})
 
 // 最近活动
 const recentActivities = ref([

+ 48 - 21
apps/web/src/views/Editor.vue

@@ -50,6 +50,8 @@
 			<el-splitter-panel>
 				<div class="h-full w-full" @drop="onDrop">
 					<Workflow
+						ref="workflowRef"
+						:id="workflow?.id"
 						:workflow="workflow"
 						:nodeMap="nodeMap"
 						@click:node="handleSelectNode"
@@ -115,12 +117,6 @@ layout?.setMainStyle({
 	padding: '0px'
 })
 
-const { onDragOver, onDrop, onDragLeave } = useDragAndDrop({
-	addNodes: (node) => {
-		handleNodeCreate(node)
-	}
-})
-
 const footerHeight = ref(32)
 const route = useRoute()
 const router = useRouter()
@@ -150,6 +146,13 @@ const saveVarsTimer = ref<number | undefined>(undefined)
 const isHydrating = ref(false)
 const notifyTimestamps = new Map<string, number>()
 
+const { onDragOver, onDrop, onDragLeave } = useDragAndDrop({
+	id,
+	addNodes: (node) => {
+		handleNodeCreate(node)
+	}
+})
+
 const normalizeNodeType = (node: any) => {
 	const sourceNodeType = node?.nodeType || node?.data?.nodeType || node?.data?.type || node?.type
 	return sourceNodeType || 'code'
@@ -410,7 +413,7 @@ const nodeID = ref('')
 const setterVisible = ref(false)
 const runVisible = ref(false)
 const pendingSetterInit = new Set<string>()
-
+const workflowRef = ref<InstanceType<typeof Workflow>>()
 const handleRunSelectedNode = async () => {
 	if (!workflow.value?.id) {
 		ElMessage.warning('请先选择需要运行的节点')
@@ -500,9 +503,19 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
 
 	const nodeToAdd = nodeMap[value.type]?.schema
 
+	// 获取当前画布的中心点
+	const viewport = workflowRef.value?.getVueFlow()?.viewport
+	let centerX = 0
+	let centerY = 0
+	if (viewport) {
+		// 计算当前中心点坐标(相对于画布)
+		centerX = (-viewport.value.x + window.innerWidth / 2) / viewport.value.zoom
+		centerY = (-viewport.value.y + window.innerHeight / 2) / viewport.value.zoom
+	}
+
 	// 如果存在对应节点则添加
 	if (nodeToAdd) {
-		const position = value.position || nodeToAdd.position
+		const position = value.position || { x: centerX, y: centerY } || nodeToAdd.position
 
 		const newNode = {
 			...nodeToAdd,
@@ -577,17 +590,27 @@ const handleDelete = () => {
  * 创建连线
  */
 const onCreateConnection = async (connection: Connection) => {
-	const { source, target } = connection
-	// TODO: 处理带handle的情况
+	const { source, target, sourceHandle } = connection
+
+	const params: {
+		appAgentId: string
+		source: string
+		target: string
+		zIndex: number
+		sourceHandle?: string
+	} = {
+		appAgentId: workflow.value.id,
+		source,
+		target,
+		zIndex: 1
+	}
+
+	if (sourceHandle && sourceHandle !== 'source' && sourceHandle !== 'target') {
+		params.sourceHandle = sourceHandle
+	}
 
 	if (!workflow.value?.edges.some((edge) => edge.source === source && edge.target === target)) {
-		const response = await agent.postAgentDoNewEdge({
-			appAgentId: workflow.value.id,
-			source,
-			target,
-			// sourceHandle: sourceHandle!,
-			zIndex: 1
-		})
+		const response = await agent.postAgentDoNewEdge(params)
 
 		if (handleApiResult(response, '节点已添加', '新增节点失败')) {
 			await loadAgentWorkflow(workflow.value.id)
@@ -648,11 +671,15 @@ const hangleUpdateNodeData = (id: string, data: any) => {
 			...node.data,
 			...data
 		}
-		// if (isEqualNodeData(node.data, nextData)) {
-		// 	return
-		// }
+
 		node.data = nextData
-		console.log('hangleUpdateNodeData', id, node.data)
+
+		if (node.nodeType === 'if-else') {
+			const cases = nextData.cases || []
+			const offsetHeight = (cases.length > 1 ? cases.length - 1 : 0) * 32
+			node.height = 96 + offsetHeight
+		}
+
 		agent
 			.postAgentDoUpdateAgentNode(buildUpdateNodePayload(node))
 			.then((response) => {

+ 1 - 0
apps/web/vite.config.ts

@@ -15,6 +15,7 @@ export default defineConfig(({ mode }) => {
 	const env = loadEnv(mode, process.cwd())
 
 	return {
+		base: './',
 		plugins: [
 			vue(),
 			vueJsx(),

+ 16 - 5
packages/api-client/request.ts

@@ -89,10 +89,10 @@ class HttpClient {
 		customErrorInterceptor?: InterceptorsConfig['requestErrorInterceptor']
 	): void {
 		const defaultInterceptor = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
-			const search = getParams(window.location.href)
-			const enterpriseCode = search?.['enterpriseCode'] || 'a'
+			// const search = getParams(window.location.href)
+			// const enterpriseCode = search?.['enterpriseCode'] || 'a'
 			const token =
-				localStorage.getItem('token_' + enterpriseCode) ||
+				localStorage.getItem('oauth2token') ||
 				document.cookie.match(new RegExp('(^| )' + 'x-sessionId_b' + '=([^;]*)(;|$)'))?.[2]
 
 			// 添加token
@@ -100,7 +100,7 @@ class HttpClient {
 				if (!config.headers) {
 					config.headers = {} as InternalAxiosRequestConfig['headers']
 				}
-				config.headers.Authorization = token
+				config.headers.Authorization = JSON.parse(token) || token
 			}
 
 			return config
@@ -123,8 +123,19 @@ class HttpClient {
 		customInterceptor?: InterceptorsConfig['responseInterceptor'],
 		customErrorInterceptor?: InterceptorsConfig['responseErrorInterceptor']
 	): void {
+		const defaultInterceptor = (
+			response: AxiosResponse<ResponseData<any>>
+		): AxiosResponse<ResponseData<any>> => {
+			// 会话丢失 重定向到/登录页
+			if (response.data?.code === 9999) {
+				window.location.href = '/'
+			}
+
+			return response
+		}
+
 		this.responseInterceptorId = this.instance.interceptors.response.use(
-			customInterceptor,
+			customInterceptor || defaultInterceptor,
 			customErrorInterceptor
 		)
 	}

Разница между файлами не показана из-за своего большого размера
+ 3083 - 561
packages/api-service/agent.openapi.json


+ 165 - 76
packages/api-service/servers/api/agent.ts

@@ -59,71 +59,21 @@ export async function postAgentDoExecute(
     appAgentId: string
     start_node_id: string
     is_debugger: boolean
+    params: Record<string, any>
   },
   options?: { [key: string]: any }
 ) {
-  return request<{
-    isSuccess: boolean
-    code: number
-    result: {
-      agent: {
-        conversation_variables: string[]
-        creationTime: string
-        creatorUserId: string
-        env_variables: { is_require?: boolean; name?: string; type?: string; value?: string }[]
-        id: string
-        isDeleted: boolean
-        name: string
-        profilePhoto: string
-        remark: string
-        updateTime: string
-        viewPort: { x: number; y: number; zoom: number }
-      }
-      runVariable: {
-        session: Record<string, any>
-        '492048da-6f33-4a36-adc5-cff4b973b053': {
-          headers: {
-            'transfer-Encoding': string
-            'access-Control-Expose-Headers': string
-            server: string
-            'access-Control-Allow-Credentials': string
-            connection: string
-            'access-Control-Max-Age': string
-            date: string
-            'x-proxy-pass': string
-            'content-Type': string
-          }
-          status_code: number
-          body: string
-          $execute_result: {
-            code: number
-            data: {
-              cookieList: string[]
-              error: boolean
-              statusCode: number
-              success: boolean
-              throwableMsg: string
-            }
-            error: boolean
-            isSelected: boolean
-            msg: string
-            success: boolean
-            ts: string
-          }
-        }
-        env: { api_address: string }
-      }
-      run_nodes: string[]
+  return request<{ isSuccess: boolean; code: number; result: string; isAuthorized: boolean }>(
+    '/api/agent/doExecute',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
     }
-    isAuthorized: boolean
-  }>('/api/agent/doExecute', {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json'
-    },
-    data: body,
-    ...(options || {})
-  })
+  )
 }
 
 /** 智能体添加节点 POST /api/agent/doNewAgentNode */
@@ -134,7 +84,7 @@ export async function postAgentDoNewAgentNode(
     width: number
     height: number
     selected: boolean
-    nodeType: 'custom' | 'start' | 'end' | 'condition' | 'task' | 'http-request'
+    nodeType: string
     zIndex: number
     parentId: string
   },
@@ -158,7 +108,7 @@ export async function postAgentDoNewEdge(
   body: {
     appAgentId: string
     source: string
-    sourceHandle?: string
+    sourceHandle: string
     target: string
     zIndex: number
   },
@@ -182,11 +132,7 @@ export async function postAgentDoSaveAgentVariables(
   body: {
     appAgentId: string
     conversation_variables: string[]
-    env_variables: {
-      name: string
-      value: string
-      type: 'string' | 'number' | 'boolean' | 'object' | 'array'
-    }[]
+    env_variables: { name?: string; value?: string; type?: string }[]
   },
   options?: { [key: string]: any }
 ) {
@@ -233,7 +179,7 @@ export async function postAgentDoUpdateAgentNode(
     width: number
     height: number
     selected: boolean
-    nodeType: 'custom' | 'start' | 'end' | 'condition' | 'task' | 'http-request'
+    nodeType: string
     zIndex: number
     data: Record<string, any>
   },
@@ -264,16 +210,97 @@ export async function postAgentGetAgentInfo(
     code: number
     result: {
       conversation_variables: string[]
-      edges: string[]
-      env_variables: {
-        is_require?: boolean
-        name: string
-        type: 'string' | 'number' | 'boolean' | 'object' | 'array'
-        value: string
+      edges: {
+        appAgentId: string
+        creationTime: string
+        data: { isInLoop: boolean; sourceType: string; targetType: string }
+        id: string
+        isDeleted: boolean
+        selected: boolean
+        source: string
+        sourceHandle: string
+        target: string
+        targetHandle: string
+        type: string
+        updateTime: string
+        zIndex: number
       }[]
+      env_variables: { is_require?: boolean; name?: string; type?: string; value?: string }[]
       id: string
       name: string
-      nodes: API.AgentNode[]
+      nodes: {
+        appAgentId: string
+        creationTime: string
+        creatorUserId: string
+        data: {
+          outputs: { name: string; describe: string; is_require: boolean; type: string }[]
+          bodyType: string
+          exception: string
+          ssl_verify: boolean
+          body: { data: { type: string; value: string; key: string }[]; type: string }
+          title: string
+          type: string
+          error_strategy: string
+          retry_config: { max_retries: number; retry_enabled: boolean; retry_interval: number }
+          authorization: { type: string; config: { api_key: string; header: string; type: string } }
+          output: { headers: string[]; status_code: number; files: string[]; body: string }
+          timeout_config: {
+            max_write_timeout: number
+            max_read_timeout: number
+            max_connect_timeout: number
+          }
+          exceptionDefaultValue: { headers: string; status_code: number; body: string }
+          id: string
+          selected: boolean
+          height: number
+          errorConfig: { retry_delay: number; retry: boolean; max_retry: number }
+          output_can_alter: boolean
+          timeoutConfig: { read: number; write: number; connect: number }
+          variables: { name: string; type: string; value: string }[]
+          method: string
+          isInIteration: boolean
+          default_value: string[]
+          params: string[]
+          nodeType: string
+          url: string
+          width: number
+          verifySSL: boolean
+          heads: string[]
+          position: { x: number; y: number }
+          desc: string
+          isInLoop: boolean
+          filter_by: {
+            conditions: { varType?: string; comparison_operator?: string; right_value?: string }[]
+            enabled: boolean
+          }
+          limit: { size: number; enabled: boolean }
+          order_by: { value: string; enabled: boolean; key: string }
+          extract_by: { serial: string; enabled: boolean }
+          code?: string
+          code_language?: string
+          cases?: {
+            logical_operator: string
+            id: string
+            conditions: {
+              varType: string
+              left_value: string
+              comparison_operator: string
+              right_value: string
+            }[]
+          }[]
+        }
+        height: number
+        id: string
+        isDeleted: boolean
+        position: { x: number; y: number }
+        selected: boolean
+        type: string
+        updateTime: string
+        width: number
+        zIndex: number
+        deleterUserId?: string
+        deletionTime?: string
+      }[]
       profilePhoto: string
       viewPort: { x: number; y: number; zoom: number }
     }
@@ -287,3 +314,65 @@ export async function postAgentGetAgentInfo(
     ...(options || {})
   })
 }
+
+/** 获取智能体列表 POST /api/agent/getAgentList */
+export async function postAgentGetAgentList(
+  // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
+  params: API.postAgentGetAgentListParams,
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: {
+      currentPage: number
+      hasNextPage: boolean
+      hasPreviousPage: boolean
+      model: {
+        conversation_variables: string[]
+        env_variables: { name: string; type: string; value: string }[]
+        id: string
+        name: string
+        profilePhoto: string
+        viewPort: { x: number; y: number; zoom: number }
+      }[]
+      pageSize: number
+      totalCount: number
+      totalPages: number
+    }
+    isAuthorized: boolean
+  }>('/api/agent/getAgentList', {
+    method: 'POST',
+    params: {
+      ...params
+    },
+    ...(options || {})
+  })
+}
+
+/** 根据节点id,获取节点之前的所有变量列表 POST /api/agent/getPrevNodeOutVariableList */
+export async function postAgentGetPrevNodeOutVariableList(
+  body: {
+    node_id: string
+    varTypeList: string[]
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: {
+      id: string
+      name: string
+      variableList: { expression: string; name: string; type: string }[]
+    }[]
+    isAuthorized: boolean
+  }>('/api/agent/getPrevNodeOutVariableList', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}

+ 4 - 0
packages/api-service/servers/api/typings.d.ts

@@ -54,6 +54,10 @@ declare namespace API {
     isInLoop?: boolean
   }
 
+  type postAgentGetAgentListParams = {
+    pageIndex: number
+  }
+
   type RequestBody = {
     data: RequestDataItem[]
     type: 'json' | 'form-data' | 'x-www-form-urlencoded' | 'raw' | 'binary'

+ 0 - 19
packages/workflow/index.html

@@ -1,19 +0,0 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>editor</title>
-    <style>
-      #app {
-        height: 100vh;
-        width: 100vw;
-      }
-    </style>
-  </head>
-  <body>
-    <div id="app"></div>
-    <script type="module" src="/src/main.ts"></script>
-  </body>
-</html>

+ 0 - 1
packages/workflow/src/assets/vue.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 1 - 0
packages/workflow/src/components/Canvas.vue

@@ -364,6 +364,7 @@ defineExpose({
 					@move="onUpdateNodePosition"
 					@update="onUpdateNodeAttrs"
 					@add-inner-node="emit('click:node:add', nodeProps.id, 'inner')"
+					@add-inner-edge="emit('create:connection:end', $event)"
 					@delete="onDeleteNode"
 					@run="onRunNode"
 				/>

+ 2 - 1
packages/workflow/src/components/elements/handles/HandlePort.vue

@@ -1,7 +1,7 @@
 <template>
 	<div :class="[position, type]" class="renderType flex items-center">
 		<div :class="type" class="handlePort transition-transform duration-150 relative"></div>
-		<div v-if="label" class="ml-8px text-12px text-#666">{{ label }}</div>
+		<div v-if="label" class="w-max ml-8px text-12px text-#666">{{ label }}</div>
 	</div>
 </template>
 
@@ -21,6 +21,7 @@ defineProps<{
 	border: 1px solid #989898;
 	border-radius: 50%;
 	cursor: default;
+	flex-shrink: 0;
 }
 
 .source:hover {

+ 10 - 4
packages/workflow/src/components/elements/nodes/CanvasNode.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed, provide, inject } from 'vue'
-import { Position } from '@vue-flow/core'
+import { Position, type Connection } from '@vue-flow/core'
 
 import CanvasHandle from '../handles/CanvasHandle.vue'
 import NodeRenderer from './render-types/NodeRenderer.vue'
@@ -28,6 +28,7 @@ const emit = defineEmits<{
 	delete: [id: string]
 	run: [id: string]
 	'add-inner-node': []
+	'add-inner-edge': [connection: Connection]
 }>()
 
 /**
@@ -60,7 +61,7 @@ const createEndpoint = (data: {
  */
 const inputs = computed(() => {
 	const getInputs = nodeMap?.[props.node?.data.nodeType]?.inputs
-	const inputs = typeof getInputs === 'function' ? getInputs(props.data?.data) : getInputs || []
+	const inputs = typeof getInputs === 'function' ? getInputs(props.node?.data) : getInputs || []
 
 	return (inputs as CanvasConnectionPort[]).map((target, index) =>
 		createEndpoint({
@@ -79,7 +80,7 @@ const inputs = computed(() => {
  */
 const outputs = computed(() => {
 	const getOutputs = nodeMap?.[props.node?.data.nodeType]?.outputs
-	const outputs = typeof getOutputs === 'function' ? getOutputs(props.data?.data) : getOutputs || []
+	const outputs = typeof getOutputs === 'function' ? getOutputs(props.node?.data) : getOutputs || []
 	return (outputs as CanvasConnectionPort[]).map((target, index) =>
 		createEndpoint({
 			port: target,
@@ -118,7 +119,12 @@ provide('canvas-node-data', {
 
 <template>
 	<div class="relative w-full h-full">
-		<NodeRenderer v-bind="$attrs" @update="onUpdate" @add-inner-node="emit('add-inner-node')" />
+		<NodeRenderer
+			v-bind="$attrs"
+			@update="onUpdate"
+			@add-inner-node="emit('add-inner-node')"
+			@add-inner-edge="emit('add-inner-edge', $event)"
+		/>
 
 		<template v-for="target in inputs" :key="'handle-inputs-port' + target.index">
 			<CanvasHandle v-bind="target" type="target" />

+ 13 - 5
packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue

@@ -31,20 +31,28 @@ const nodeClass = computed(() => {
 
 	return classes
 })
-const nodeData = computed(() => node?.props?.data)
+const nodeData = computed(() => node?.props?.node)
 
 const nodeType = computed(() => nodeMap?.[nodeData.value?.nodeType!])
 
+const nodeSubtitle = computed(() => {
+	const getSubtitle = nodeType.value?.getSubtitle
+	if (getSubtitle) {
+		return getSubtitle(nodeData.value)
+	}
+	return ''
+})
+
 const warningInfo = computed(() => {
 	const validate = nodeType.value?.validate
-	return validate && validate(nodeData.value)
+	return validate && validate(nodeData.value?.data)
 })
 </script>
 
 <template>
 	<div
 		:class="nodeClass"
-		class="default-node w-full h-full bg-#fff box-border border-2 border-solid border-#dcdcdc rounded-8px relative"
+		class="default-node w-full h-full bg-#fff box-border border-1.5px border-solid border-#dcdcdc rounded-8px relative"
 	>
 		<div className="w-full h-full relative flex items-center justify-center">
 			<div
@@ -58,7 +66,7 @@ const warningInfo = computed(() => {
 					:icon="nodeType?.icon ?? 'lucide:cloud'"
 					color="#ffffff"
 					class="relative z-10"
-					:size="20"
+					width="20"
 				/>
 			</div>
 		</div>
@@ -74,7 +82,7 @@ const warningInfo = computed(() => {
 		<div className="absolute w-full bottom--24px text-12px text-center text-#333">
 			<div>{{ nodeData?.name || nodeType?.displayName || '节点标题' }}</div>
 			<div className="text-12px text-center text-#999 truncate">
-				{{ nodeData?.data?.subtitle }}
+				{{ nodeSubtitle }}
 			</div>
 		</div>
 	</div>

+ 13 - 4
packages/workflow/src/components/elements/nodes/render-types/NodeLoop.vue

@@ -5,7 +5,7 @@ import { NodeResizer } from '@vue-flow/node-resizer'
 import type { OnResize } from '@vue-flow/node-resizer'
 import Canvas from '../../../Canvas.vue'
 
-import type { NodeProps, XYPosition } from '@vue-flow/core'
+import type { NodeProps, XYPosition, Connection } from '@vue-flow/core'
 import type { IWorkflowNode, CanvasConnectionPort, IWorkflowEdge } from '../../../../Interface'
 
 import '@vue-flow/node-resizer/dist/style.css'
@@ -32,11 +32,12 @@ const edges = inject<{ edges: IWorkflowEdge[] }>('vueflow')?.edges
 const childrenNodes = computed(
 	() => nodes?.filter((item) => item?.parentId === node?.props?.id) || []
 )
-console.log(childrenNodes.value, nodes)
+
 const emit = defineEmits<{
 	update: [parameters: Record<string, unknown>]
 	move: [position: XYPosition]
-	'add-inner-node': []
+	'add-inner-node': [parentId: string]
+	'add-inner-edge': [connection: Connection]
 }>()
 
 const nodeData = computed(() => node?.props?.node?.data ?? node?.props?.data)
@@ -70,9 +71,13 @@ function onResize(event: OnResize) {
 
 function onAddNode() {
 	if (!isReadOnly.value) {
-		emit('add-inner-node')
+		emit('add-inner-node', node?.props?.id!)
 	}
 }
+
+function onAddEdge(connection: Connection) {
+	emit('add-inner-edge', connection)
+}
 </script>
 
 <template>
@@ -119,6 +124,10 @@ function onAddNode() {
 					:show-control-bar="false"
 					:hide-child-node="false"
 					:zoom-to-fit="false"
+					:max-zoom="1"
+					:min-zoom="1"
+					@create:node="onAddNode"
+					@create:connection:end="onAddEdge"
 				/>
 			</div>
 		</div>

+ 3 - 1
packages/workflow/src/components/elements/nodes/render-types/NodeRenderer.vue

@@ -5,7 +5,7 @@ import NodeStickyNote from './NodeStickyNote.vue'
 import NodeLoop from './NodeLoop.vue'
 import NodeIcon from './NodeIcon.vue'
 
-import type { NodeProps } from '@vue-flow/core'
+import type { NodeProps, Connection } from '@vue-flow/core'
 import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
 
 const node = inject<{
@@ -18,6 +18,7 @@ const nodeType = computed(() => node?.props?.data?.nodeType)
 
 defineEmits<{
 	'add-inner-node': []
+	'add-inner-edge': [connection: Connection]
 }>()
 </script>
 
@@ -27,6 +28,7 @@ defineEmits<{
 		v-else-if="nodeType === 'loop' || nodeType === 'iteration'"
 		v-bind="$attrs"
 		@add-inner-node="$emit('add-inner-node')"
+		@add-inner-edge="$emit('add-inner-edge', $event)"
 	/>
 	<NodeIcon v-else-if="nodeType?.includes('-start')" v-bind="$attrs" />
 	<NodeDefault v-else v-bind="$attrs"> </NodeDefault>

+ 3 - 2
packages/workflow/src/hooks/useDragAndDrop.ts

@@ -16,12 +16,13 @@ const state = {
 }
 
 export default function useDragAndDrop(options?: {
+	id: string
 	addNodes: (nodes: { type: string; position: XYPosition }) => void
 }) {
 	const { draggedType, isDragOver, isDragging } = state
-	const { addNodes } = options || {}
+	const { id, addNodes } = options || {}
 
-	const { screenToFlowCoordinate } = useVueFlow()
+	const { screenToFlowCoordinate } = useVueFlow(id)
 
 	watch(isDragging, (dragging) => {
 		document.body.style.userSelect = dragging ? 'none' : ''

+ 0 - 10
packages/workflow/uno.config.ts

@@ -1,10 +0,0 @@
-import { defineConfig, presetWind3, presetUno } from 'unocss'
-
-export default defineConfig({
-  presets: [presetUno({ dark: 'class' }), presetWind3()],
-  theme: {
-    colors: {}
-  },
-  shortcuts: {},
-  rules: []
-})

+ 0 - 8
packages/workflow/vite.config.ts

@@ -1,8 +0,0 @@
-import { defineConfig } from 'vite'
-import vue from '@vitejs/plugin-vue'
-import UnoCss from 'unocss/vite'
-
-// https://vite.dev/config/
-export default defineConfig({
-  plugins: [vue(), UnoCss()]
-})

+ 2 - 7
pnpm-lock.yaml

@@ -155,8 +155,8 @@ importers:
         specifier: ^2.13.1
         version: 2.13.1(vue@3.5.27(typescript@5.9.3))
       lexical:
-        specifier: ^0.41.0
-        version: 0.41.0
+        specifier: 0.38.1
+        version: 0.38.1
       lexical-vue:
         specifier: ^0.14.1
         version: 0.14.1(@rsbuild/core@1.7.2)(@rspack/core@1.7.3(@swc/helpers@0.5.18))(rolldown-vite@7.2.5(@types/node@25.1.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(yaml@1.10.2))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.30)
@@ -5198,9 +5198,6 @@ packages:
   lexical@0.38.1:
     resolution: {integrity: sha512-T2/2pnAZ160kA9nlmkfSpfzke7SuNo1DhMYynTWd36vvZWKEOY0PL3SMSeGSI3TAx2vbO3aHcj/FNrYKAoUiRg==}
 
-  lexical@0.41.0:
-    resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==}
-
   lib0@0.2.117:
     resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
     engines: {node: '>=16'}
@@ -13643,8 +13640,6 @@ snapshots:
 
   lexical@0.38.1: {}
 
-  lexical@0.41.0: {}
-
   lib0@0.2.117:
     dependencies:
       isomorphic.js: 0.2.5