Kaynağa Gözat

feat: 添加自定义节点解析

jiaxing.liao 2 hafta önce
ebeveyn
işleme
c02c65aa1c

+ 2 - 0
apps/web/components.d.ts

@@ -12,6 +12,7 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAside: typeof import('element-plus/es')['ElAside']
     ElAvatar: typeof import('element-plus/es')['ElAvatar']
     ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
@@ -79,6 +80,7 @@ declare module 'vue' {
 
 // For TSX support
 declare global {
+  const ElAlert: typeof import('element-plus/es')['ElAlert']
   const ElAside: typeof import('element-plus/es')['ElAside']
   const ElAvatar: typeof import('element-plus/es')['ElAvatar']
   const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']

+ 11 - 1
apps/web/src/components/VarLabel/index.vue

@@ -75,6 +75,10 @@ const valueInfo = computed(() => {
 		}
 	}
 })
+
+const isImageIcon = computed(() => {
+	return !!valueInfo.value?.icon && valueInfo.value.icon.startsWith('data:image/')
+})
 </script>
 
 <template>
@@ -102,7 +106,13 @@ const valueInfo = computed(() => {
 				class="var-select__item-prefix env text-10px"
 				:style="{ background: nodeMap[valueInfo?.nodeType ?? '']?.iconColor ?? '' }"
 			>
-				<Icon v-if="valueInfo?.icon" :icon="valueInfo?.icon!" :size="18" />
+				<img
+					v-if="isImageIcon"
+					:src="valueInfo?.icon"
+					alt="node icon"
+					class="w-18px h-18px object-contain"
+				/>
+				<Icon v-else-if="valueInfo?.icon" :icon="valueInfo?.icon!" :size="18" />
 				<span v-else class="text-6px">VAR</span>
 			</span>
 			{{ valueInfo.nodeName }}

+ 11 - 1
apps/web/src/features/RunWorkflow/components/TraceTab.vue

@@ -44,6 +44,10 @@ function nodeIcon(node: RunnerNodeState) {
 	return nodeMap[node.nodeType]?.icon || 'lucide:circle'
 }
 
+function isImageIcon(icon?: string) {
+	return !!icon && icon.startsWith('data:image/')
+}
+
 function nodeDisplayName(node: RunnerNodeState) {
 	if (node.nodeName) return node.nodeName
 	return (
@@ -83,7 +87,13 @@ function formatDisplayValue(value: unknown) {
 						<div class="trace-title">
 							<div class="trace-title__left">
 								<div class="trace-icon">
-									<Icon :icon="nodeIcon(node)" :size="18" />
+									<img
+										v-if="isImageIcon(nodeIcon(node))"
+										:src="nodeIcon(node)"
+										alt="node icon"
+										class="w-18px h-18px object-contain"
+									/>
+									<Icon v-else :icon="nodeIcon(node)" :size="18" />
 								</div>
 								<span class="trace-name">{{ nodeDisplayName(node) }}</span>
 							</div>

+ 21 - 17
apps/web/src/features/nodeLibary/index.vue

@@ -17,6 +17,7 @@ interface NodeItem {
 	name: string
 	description?: string
 	icon?: string
+	isImageIcon?: boolean
 	iconColor?: string
 	raw: INodeType
 }
@@ -77,6 +78,7 @@ const canAddNodeType = (nodeType: string) => {
 }
 
 const getGroupId = (group?: string): NodeLibraryGroupId => GROUP_ALIASES[group || ''] || 'other'
+const isImageIcon = (icon?: string) => !!icon && icon.startsWith('data:image/')
 
 const materials = computed<NodeGroup[]>(() => {
 	const groupMap = new Map<string, NodeGroup>()
@@ -107,21 +109,13 @@ const materials = computed<NodeGroup[]>(() => {
 				name: getNodeDisplayName(node.schema.nodeType),
 				description: getNodeDescription(node.schema.nodeType),
 				icon: node.icon,
+				isImageIcon: isImageIcon(node.icon),
 				iconColor: node.iconColor,
 				raw: node
 			})
 		})
 
-	// 自定义
-	const custom = {
-		id: 'custom',
-		label: groupLabels.value.custom,
-		nodes: []
-	}
-
-	return GROUP_ORDER.filter((groupId) => groupMap.has(groupId))
-		.map((groupId) => groupMap.get(groupId)!)
-		.concat([custom])
+	return GROUP_ORDER.filter((groupId) => groupMap.has(groupId)).map((groupId) => groupMap.get(groupId)!)
 })
 
 const activeGroup = ref('')
@@ -155,14 +149,24 @@ const onAddNode = (value: NodeItem) => {
 						@click="onAddNode(item)"
 						@dragstart="onDragStart($event, item.type)"
 					>
-						<Icon
-							:icon="item.icon || 'lucide:square'"
-							height="16"
-							width="16"
-							class="mr-2 p-1 rounded"
+						<div
+							class="mr-2 p-1 rounded flex items-center justify-center"
 							:style="{ backgroundColor: item.iconColor || '#6172f3' }"
-							color="#fff"
-						/>
+						>
+							<img
+								v-if="item.isImageIcon"
+								:src="item.icon"
+								alt="node icon"
+								class="w-16px h-16px object-contain"
+							/>
+							<Icon
+								v-else
+								:icon="item.icon || 'lucide:square'"
+								height="16"
+								width="16"
+								color="#fff"
+							/>
+						</div>
 						<div class="flex flex-col">
 							<span>{{ item.name }}</span>
 							<span v-if="item.description" class="desc">{{ item.description }}</span>

+ 11 - 1
apps/web/src/features/setter/index.vue

@@ -48,6 +48,10 @@ const nodeInfo = computed(() => {
 		: undefined
 })
 
+const isImageIcon = computed(() => {
+	return !!nodeInfo.value?.icon && nodeInfo.value.icon.startsWith('data:image/')
+})
+
 const closeDrawer = () => {
 	emit('update:visible', false)
 }
@@ -180,7 +184,13 @@ provide('nodeVars', nodeVars)
 							class="h-22px w-22px flex items-center justify-center rounded-lg shrink-0"
 							:style="{ background: nodeInfo?.iconColor }"
 						>
-							<Icon :icon="nodeInfo?.icon" color="#fff" :size="14" />
+							<img
+								v-if="isImageIcon"
+								:src="nodeInfo?.icon"
+								alt="node icon"
+								class="w-14px h-14px object-contain"
+							/>
+							<Icon v-else :icon="nodeInfo?.icon" color="#fff" :size="14" />
 						</span>
 						<Input
 							v-model="name"

+ 1 - 1
apps/web/src/i18n/locales/en-us.ts

@@ -1275,7 +1275,7 @@ export default {
 				logic: 'Logic',
 				tool: 'Tool',
 				other: 'Other',
-				Custom: 'Custom'
+				custom: 'Custom'
 			}
 		},
 		startSetter: {

+ 13 - 7
apps/web/src/main.ts

@@ -12,18 +12,24 @@ import 'virtual:svg-icons-register'
 import { initTheme } from '@/theme'
 import { lightTheme } from '@/theme/light'
 import { darkTheme } from '@/theme/dark'
+import { initializeCustomNodes } from '@/nodes/custom/initialize-custom-nodes'
 // import 'monaco-editor/esm/vs/editor/editor.main.css';
 initTheme(lightTheme, darkTheme)
 import 'normalize.css'
 import 'virtual:uno.css'
 
-const app = createApp(App)
-app.use(store)
-app.use(router)
+const bootstrap = async () => {
+	await initializeCustomNodes()
 
-app.use(ElementPlus)
+	const app = createApp(App)
+	app.use(store)
+	app.use(router)
+	app.use(ElementPlus)
 
-app.provide('i18n', i18n)
-app.config.globalProperties.$t = (key: string) => i18n.t(key)
+	app.provide('i18n', i18n)
+	app.config.globalProperties.$t = (key: string) => i18n.t(key)
 
-app.mount('#app')
+	app.mount('#app')
+}
+
+void bootstrap()

+ 2 - 2
apps/web/src/nodes/Interface.ts

@@ -80,7 +80,7 @@ export interface ConfigSchema {
 	renderItem?: (renderCallbackParams: RenderCallbackParams) => Element
 }
 
-type EndpointType =
+export type EndpointType =
 	| string
 	| { type: string; label: string; id: string; labelStyle?: Record<string, any> }
 
@@ -92,7 +92,7 @@ export interface INodeType {
 	/**
 	 * 所属分组
 	 */
-	group?: 'start' | 'logic' | 'data' | 'tool' | 'other'
+	group?: 'start' | 'logic' | 'data' | 'tool' | 'other' | 'custom'
 	/**
 	 * 展示名称
 	 */

+ 8 - 0
apps/web/src/nodes/_base/PromptEditor/plugins/VarLaberPickerPlugin.vue

@@ -198,6 +198,8 @@ const normalizeTypeLabel = (type?: string) => {
 	return labelMap[type] || VARIABLE_TYPE_OPTIONS.find((item) => item.value === type)?.label || type
 }
 
+const isImageIcon = (icon?: string) => !!icon && icon.startsWith('data:image/')
+
 const onQueryChange = (payload: string | null) => {
 	queryKeyword.value = payload || ''
 }
@@ -341,6 +343,12 @@ onUnmounted(() => {
 									>
 										<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>
+										<img
+											v-else-if="isImageIcon(NODE_MAP[item.option.nodeType || '']?.icon)"
+											:src="NODE_MAP[item.option.nodeType || '']?.icon || ''"
+											alt="node icon"
+											class="w-16px h-16px object-contain"
+										/>
 										<Icon
 											v-else-if="item.option.nodeType && NODE_MAP[item.option.nodeType || '']?.icon"
 											:icon="NODE_MAP[item.option.nodeType || '']?.icon || ''"

+ 9 - 1
apps/web/src/nodes/_base/VarSelect.vue

@@ -56,7 +56,13 @@
 							>
 								<span v-if="group.id === 'env'" class="text-6px">ENV</span>
 								<span v-if="group.id === 'sys'" class="text-6px">SYS</span>
-								<Icon v-if="group?.type" :icon="nodeMap[group?.type]?.icon!" :size="18" />
+								<img
+									v-else-if="isImageIcon(nodeMap[group?.type ?? '']?.icon)"
+									:src="nodeMap[group?.type ?? '']?.icon"
+									alt="node icon"
+									class="w-18px h-18px object-contain"
+								/>
+								<Icon v-else-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">{{ normalizeTypeLabel(item.type) }}</span>
@@ -163,6 +169,8 @@ const normalizeTypeLabel = (type?: VarType) => {
 	return labelMap[type] || VARIABLE_TYPE_OPTIONS.find((item) => item.value === type)?.label || ''
 }
 
+const isImageIcon = (icon?: string) => !!icon && icon.startsWith('data:image/')
+
 /**
  * 过滤变量列表
  */

+ 194 - 0
apps/web/src/nodes/custom/CustomNodeSetter.vue

@@ -0,0 +1,194 @@
+<script setup lang="ts">
+import { computed, watch } from 'vue'
+import NodeRuntimeConfig from '@/nodes/_base/NodeRuntimeConfig.vue'
+import { useSetterModel } from '@/nodes/src/_shared/useSetterModel'
+import SecretInput from '@/features/secretInput/SecretInput.vue'
+import { nodeMap } from '../src'
+
+interface CustomNodeOption {
+	label?: string
+	value?: string | number | boolean
+}
+
+interface CustomNodeParameter {
+	name?: string
+	label?: string
+	description?: string
+	type?: string
+	required?: boolean
+	defaultValue?: unknown
+	placeholder?: string
+	min?: number
+	max?: number
+	options?: CustomNodeOption[]
+}
+
+const props = defineProps<{
+	data: Record<string, any>
+}>()
+
+const emit = defineEmits<{
+	update: [data: Record<string, any>]
+}>()
+
+const formData = useSetterModel<Record<string, any>>(props, emit)
+console.log(111, formData, nodeMap)
+const parameters = computed<CustomNodeParameter[]>(
+	() => nodeMap?.[formData.value?.nodeType]?.parameters || []
+)
+
+const normalizeType = (type?: string) => {
+	const raw = `${type || ''}`.toLowerCase()
+	if (['text-input', 'text', 'string', 'input'].includes(raw)) return 'text-input'
+	if (['secret-input', 'password'].includes(raw)) return 'secret-input'
+	if (['text-area', 'textarea'].includes(raw)) return 'text-area'
+	if (['number', 'int', 'integer', 'float', 'double'].includes(raw)) return 'number'
+	if (['select', 'enum'].includes(raw)) return 'select'
+	if (['checkbox', 'switch', 'boolean', 'bool'].includes(raw)) return 'checkbox'
+	if (raw === 'edg-link') return 'edg-link'
+	return 'text-input'
+}
+
+const normalizeDefaultValue = (parameter: CustomNodeParameter) => {
+	const type = normalizeType(parameter.type)
+	if (parameter.defaultValue !== undefined) {
+		return parameter.defaultValue
+	}
+	if (type === 'checkbox') return false
+	if (type === 'number') return undefined
+	return ''
+}
+
+watch(
+	parameters,
+	(newParameters) => {
+		for (const parameter of newParameters) {
+			const key = parameter.name
+			if (!key) continue
+			if (normalizeType(parameter.type) === 'edg-link') continue
+
+			formData.value[key] = normalizeDefaultValue(parameter)
+		}
+	},
+	{ immediate: true, deep: true }
+)
+
+const getSelectOptions = (parameter: CustomNodeParameter): CustomNodeOption[] => {
+	if (!Array.isArray(parameter.options)) return []
+	return parameter.options
+		.map((item) => ({
+			label: item?.label ?? `${item?.value ?? ''}`,
+			value: item?.value ?? item?.label ?? ''
+		}))
+		.filter((item) => item.label !== undefined)
+}
+
+const getLabel = (parameter: CustomNodeParameter) => parameter.label || parameter.name || 'param'
+
+const getParameterValue = (parameter: CustomNodeParameter) => {
+	const key = parameter.name
+	if (!key) return undefined
+	return formData.value?.[key]
+}
+
+const setParameterValue = (parameter: CustomNodeParameter, value: unknown) => {
+	const key = parameter.name
+	if (!key) return
+
+	formData.value[key] = value
+}
+</script>
+
+<template>
+	<el-scrollbar class="w-full box-border">
+		<el-form label-position="top" label-width="120px" class="p-12px">
+			<el-form-item
+				v-for="parameter in parameters"
+				:key="parameter.name || parameter.label"
+				:required="!!parameter.required"
+			>
+				<template #label>
+					<div class="w-full flex items-center justify-between beautify">
+						<label class="text-14px font-bold text-gray-700">{{ getLabel(parameter) }}</label>
+					</div>
+					<div v-if="parameter.description" class="text-12px text-gray-500">
+						{{ parameter.description }}
+					</div>
+				</template>
+				<!-- <el-alert
+					v-if="isEdgeLink(parameter)"
+					type="info"
+					show-icon
+					:closable="false"
+					:title="parameter.description || 'Edge-link output, no value input needed'"
+				/> -->
+
+				<el-input
+					v-if="normalizeType(parameter.type) === 'text-input'"
+					:model-value="getParameterValue(parameter)"
+					:placeholder="parameter.placeholder || ''"
+					@update:model-value="setParameterValue(parameter, $event)"
+				/>
+
+				<SecretInput
+					v-else-if="normalizeType(parameter.type) === 'secret-input'"
+					:model-value="getParameterValue(parameter) as any"
+					:placeholder="parameter.placeholder || ''"
+					@update:model-value="setParameterValue(parameter, $event)"
+				/>
+
+				<el-input
+					v-else-if="normalizeType(parameter.type) === 'text-area'"
+					:model-value="getParameterValue(parameter)"
+					:placeholder="parameter.placeholder || ''"
+					type="textarea"
+					:autosize="{ minRows: 3, maxRows: 8 }"
+					@update:model-value="setParameterValue(parameter, $event)"
+				/>
+
+				<el-input-number
+					v-else-if="normalizeType(parameter.type) === 'number'"
+					:model-value="getParameterValue(parameter)"
+					:min="parameter.min"
+					:max="parameter.max"
+					controls-position="right"
+					style="width: 100%"
+					@update:model-value="setParameterValue(parameter, $event)"
+				/>
+
+				<el-select
+					v-else-if="normalizeType(parameter.type) === 'select'"
+					:model-value="getParameterValue(parameter)"
+					style="width: 100%"
+					@update:model-value="setParameterValue(parameter, $event)"
+				>
+					<el-option
+						v-for="option in getSelectOptions(parameter)"
+						:key="`${parameter.name}-${option.value}`"
+						:label="option.label"
+						:value="option.value"
+					/>
+				</el-select>
+
+				<el-switch
+					v-else-if="normalizeType(parameter.type) === 'checkbox'"
+					:model-value="Boolean(getParameterValue(parameter))"
+					@update:model-value="setParameterValue(parameter, $event)"
+				/>
+
+				<el-input
+					v-else
+					:model-value="getParameterValue(parameter)"
+					:placeholder="parameter.placeholder || ''"
+					@update:model-value="setParameterValue(parameter, $event)"
+				/>
+
+				<div v-if="parameter.description" class="text-12px text-gray-500 mt-6px">
+					{{ parameter.description }}
+				</div>
+			</el-form-item>
+		</el-form>
+
+		<NodeRuntimeConfig v-model="formData" />
+	</el-scrollbar>
+</template>

+ 57 - 0
apps/web/src/nodes/custom/initialize-custom-nodes.ts

@@ -0,0 +1,57 @@
+import { agent } from '@repo/api-service'
+import { load as parseYaml } from 'js-yaml'
+
+import type { INodeType } from '@/nodes/Interface'
+import { registerDynamicNodes } from '@/nodes'
+import { toCustomNodeType, type CustomNodeSource } from './parser'
+
+const normalizeSource = (item: unknown): CustomNodeSource | null => {
+	if (!item) return null
+
+	if (typeof item === 'string') {
+		try {
+			const parsed = parseYaml(item)
+			if (parsed && typeof parsed === 'object') {
+				return parsed as CustomNodeSource
+			}
+		} catch (error) {
+			console.warn('[custom-node] yaml parse failed', error)
+		}
+		return null
+	}
+
+	if (typeof item === 'object') {
+		return item as CustomNodeSource
+	}
+
+	return null
+}
+
+const toDynamicNodes = (rawList: unknown[]): INodeType[] => {
+	const nodes: INodeType[] = []
+	for (const item of rawList) {
+		const source = normalizeSource(item)
+		if (!source) continue
+		const node = toCustomNodeType(source)
+		if (node) nodes.push(node)
+	}
+	return nodes
+}
+
+export const initializeCustomNodes = async () => {
+	try {
+		const response = await agent.postAgentGetSupportCustomAgentNodeYamlList({})
+		if (!response?.isSuccess) {
+			console.warn('[custom-node] backend returned unsuccessful response')
+			return
+		}
+
+		const rawList = Array.isArray(response?.result) ? response.result : []
+		const dynamicNodes = toDynamicNodes(rawList as unknown[])
+		if (!dynamicNodes.length) return
+
+		registerDynamicNodes(dynamicNodes)
+	} catch (error) {
+		console.error('[custom-node] initialize failed', error)
+	}
+}

+ 193 - 0
apps/web/src/nodes/custom/parser.ts

@@ -0,0 +1,193 @@
+import type { EndpointType, INodeType, NodeVariable } from '@/nodes/Interface'
+import { getNodeDisplayName } from '@/nodes/i18n'
+import CustomNodeSetter from './CustomNodeSetter.vue'
+
+interface CustomIdentity {
+	type?: string
+	name?: string
+	label?: string
+	description?: string
+	icon40?: string
+	author?: string
+}
+
+interface CustomOption {
+	label?: string
+	value?: string | number | boolean
+}
+
+interface CustomParameter {
+	name?: string
+	label?: string
+	description?: string
+	llm_description?: string
+	type?: string
+	required?: boolean
+	defaultValue?: unknown
+	placeholder?: string
+	min?: number
+	max?: number
+	options?: CustomOption[]
+}
+
+export interface CustomNodeSource {
+	identity?: CustomIdentity
+	parameters?: CustomParameter[]
+	[key: string]: unknown
+}
+
+const DEFAULT_NODE_ICON = 'lucide:puzzle'
+const DEFAULT_NODE_ICON_COLOR = '#7c3aed'
+
+const toArray = <T>(value: T[] | undefined): T[] => (Array.isArray(value) ? value : [])
+
+const normalizeNodeType = (raw: CustomNodeSource) => {
+	const type =
+		raw.identity?.type ||
+		raw.identity?.name ||
+		(typeof raw.nodeType === 'string' ? raw.nodeType : '') ||
+		(typeof raw.type === 'string' ? raw.type : '')
+	return type?.trim?.() || ''
+}
+
+const normalizeNodeName = (raw: CustomNodeSource, nodeType: string) => {
+	const name = raw.identity?.name || nodeType
+	return name?.trim?.() || nodeType
+}
+
+const normalizeDisplayName = (raw: CustomNodeSource, nodeType: string) => {
+	return (
+		raw.identity?.label?.trim?.() ||
+		raw.identity?.name?.trim?.() ||
+		getNodeDisplayName(nodeType) ||
+		nodeType
+	)
+}
+
+const normalizeDescription = (raw: CustomNodeSource) => {
+	return raw.identity?.description?.trim?.() || ''
+}
+
+const resolveCustomIcon = (icon40?: string) => {
+	const raw = `${icon40 || ''}`.trim()
+	if (!raw) {
+		return DEFAULT_NODE_ICON
+	}
+
+	if (raw.startsWith('data:image/')) {
+		return raw
+	}
+
+	const compact = raw.replace(/\s+/g, '')
+	const isBase64Like = compact.length > 64 && /^[A-Za-z0-9+/=]+$/.test(compact)
+	if (isBase64Like) {
+		return `data:image/png;base64,${compact}`
+	}
+
+	return raw
+}
+
+const getFormType = (type?: string) => {
+	const raw = `${type || ''}`.toLowerCase()
+	if (['text-input', 'text', 'string', 'input'].includes(raw)) return 'string'
+	if (['secret-input', 'password'].includes(raw)) return 'string'
+	if (['text-area', 'textarea'].includes(raw)) return 'string'
+	if (['number', 'int', 'integer', 'float', 'double'].includes(raw)) return 'number'
+	if (['checkbox', 'switch', 'boolean', 'bool'].includes(raw)) return 'boolean'
+	if (['select', 'enum'].includes(raw)) return 'string'
+	return 'string'
+}
+
+const isEdgeLinkParameter = (parameter: CustomParameter) => {
+	return `${parameter.type || ''}`.toLowerCase() === 'edg-link'
+}
+
+const toNodeVariableType = (type?: string): NodeVariable['type'] => {
+	const value = getFormType(type)
+	if (value === 'number') return 'number'
+	if (value === 'boolean') return 'boolean'
+	return 'string'
+}
+
+const buildDefaultParameters = (parameters: CustomParameter[]) => {
+	const defaults: Record<string, unknown> = {}
+
+	for (const parameter of parameters) {
+		if (!parameter.name) continue
+		if (parameter.defaultValue !== undefined) {
+			defaults[parameter.name] = parameter.defaultValue
+			continue
+		}
+
+		if (getFormType(parameter.type) === 'boolean') {
+			defaults[parameter.name] = false
+			continue
+		}
+
+		defaults[parameter.name] = ''
+	}
+
+	return defaults
+}
+
+const buildOutputVariables = (parameters: CustomParameter[]): NodeVariable[] => {
+	return parameters
+		.filter((parameter) => parameter.name && !isEdgeLinkParameter(parameter))
+		.map((parameter) => ({
+			name: parameter.name as string,
+			describe: parameter.label || parameter.description || '',
+			type: toNodeVariableType(parameter.type)
+		}))
+}
+
+const buildEdgeLinkPorts = (parameters: CustomParameter[]): EndpointType[] => {
+	return parameters
+		.filter((parameter) => parameter.name && isEdgeLinkParameter(parameter))
+		.map(
+			(parameter): EndpointType => ({
+				id: parameter.name as string,
+				type: 'port',
+				label: parameter.label || parameter.name || ''
+			})
+		)
+}
+
+export const toCustomNodeType = (raw: CustomNodeSource): INodeType | null => {
+	const nodeType = normalizeNodeType(raw)
+	if (!nodeType) return null
+
+	const parameters = toArray(raw.parameters)
+	const displayName = normalizeDisplayName(raw, nodeType)
+	const nodeName = normalizeNodeName(raw, nodeType)
+	const edgeLinkPorts = buildEdgeLinkPorts(parameters)
+	const outputs: EndpointType[] = ['main', ...edgeLinkPorts]
+
+	return {
+		version: ['1'],
+		group: 'custom',
+		name: nodeName,
+		displayName,
+		description: normalizeDescription(raw),
+		icon: resolveCustomIcon(raw.identity?.icon40),
+		iconColor: DEFAULT_NODE_ICON_COLOR,
+		Setter: CustomNodeSetter,
+		inputs: ['main'],
+		outputs,
+		parameters,
+		schema: {
+			appAgentId: '',
+			parentId: '',
+			position: {
+				x: 20,
+				y: 30
+			},
+			width: 96,
+			height: 96,
+			selected: false,
+			nodeType,
+			zIndex: 1,
+
+			data: {}
+		}
+	}
+}

+ 10 - 4
apps/web/src/nodes/i18n.ts

@@ -1,6 +1,6 @@
 import i18n from '@/i18n'
 
-const NODE_GROUP_KEYS: Record<string, 'start' | 'logic' | 'data' | 'tool' | 'other'> = {
+const NODE_GROUP_KEYS: Record<string, 'start' | 'logic' | 'data' | 'tool' | 'other' | 'custom'> = {
 	start: 'start',
 	end: 'other',
 	'http-request': 'data',
@@ -23,7 +23,8 @@ const NODE_GROUP_KEYS: Record<string, 'start' | 'logic' | 'data' | 'tool' | 'oth
 	'workflow-approval': 'logic',
 	'loop-start': 'logic',
 	'iteration-start': 'logic',
-	stickyNote: 'other'
+	stickyNote: 'other',
+	custom: 'custom'
 }
 
 const NODE_META_KEYS: Record<string, string> = {
@@ -32,11 +33,16 @@ const NODE_META_KEYS: Record<string, string> = {
 
 const getNodeMetaKey = (nodeType: string) => NODE_META_KEYS[nodeType] || nodeType
 
+const getNodeMetaText = (nodeType: string, field: 'displayName' | 'description', fallback = '') => {
+	const value = i18n.tm(`nodes.meta.${getNodeMetaKey(nodeType)}.${field}`)
+	return typeof value === 'string' && value ? value : fallback
+}
+
 export const getNodeDisplayName = (nodeType: string) =>
-	i18n.t(`nodes.meta.${getNodeMetaKey(nodeType)}.displayName`)
+	getNodeMetaText(nodeType, 'displayName', nodeType)
 
 export const getNodeDescription = (nodeType: string) =>
-	i18n.t(`nodes.meta.${getNodeMetaKey(nodeType)}.description`)
+	getNodeMetaText(nodeType, 'description', '')
 
 export const getNodeGroup = (nodeType: string) => {
 	const groupKey = NODE_GROUP_KEYS[nodeType] || 'other'

+ 26 - 0
apps/web/src/nodes/src/index.ts

@@ -142,4 +142,30 @@ const nodeMap = nodes.reduce(
 	{} as Record<string, INodeType>
 )
 
+/**
+ * 动态注册节点
+ */
+export const registerDynamicNodes = (dynamicNodes: INodeType[]) => {
+	for (const node of dynamicNodes) {
+		if (!node?.name) continue
+
+		const normalizedNode = withFailBranchOutput(node)
+		const existedIndex = nodes.findIndex((item) => item.name === normalizedNode.name)
+		if (existedIndex >= 0) {
+			nodes.splice(existedIndex, 1, normalizedNode)
+		} else {
+			nodes.push(normalizedNode)
+		}
+
+		nodeMap[normalizedNode.name] = normalizedNode
+		nodeMap[normalizedNode.schema?.nodeType || normalizedNode.name] = normalizedNode
+
+		// Compatibility alias in case backend sends nodeType under `data.nodeType`.
+		const schemaNodeType = normalizedNode.schema?.nodeType || normalizedNode.schema?.data?.nodeType
+		if (schemaNodeType && schemaNodeType !== normalizedNode.name) {
+			nodeMap[schemaNodeType] = normalizedNode
+		}
+	}
+}
+
 export { nodes, nodeMap }

+ 45 - 9
apps/web/src/nodes/src/workflow-approval/index.ts

@@ -1,4 +1,9 @@
-import { NodeConnectionTypes, type INodeDataBaseSchema, type INodeType } from '../../Interface'
+import {
+	NodeConnectionTypes,
+	type INodeDataBaseSchema,
+	type INodeType,
+	type EndpointType
+} from '../../Interface'
 import Setter from './setter.vue'
 import { getNodeDescription, getNodeDisplayName } from '@/nodes/i18n'
 
@@ -6,6 +11,10 @@ export type WorkflowApprovalData = INodeDataBaseSchema & {
 	usn: string
 	jobId: string
 	workflowCode: string
+	agree_edg_id: string // "审批通过的连线id",
+	reject_edg_id: string // "拒绝的连线id",
+	cancel_edg_id: string // "取消的连线id",
+	other_edg_id: string //"其他的连线id"
 }
 
 export const workflowApprovalNode: INodeType = {
@@ -18,7 +27,39 @@ export const workflowApprovalNode: INodeType = {
 	icon: 'lucide:clipboard-check',
 	iconColor: '#f97316',
 	inputs: [NodeConnectionTypes.main],
-	outputs: [NodeConnectionTypes.main],
+	outputs: (data: WorkflowApprovalData) => {
+		const ports: EndpointType[] = []
+		if (data?.agree_edg_id) {
+			ports.push({
+				id: data.agree_edg_id,
+				type: 'port',
+				label: '通过'
+			})
+		}
+		if (data?.reject_edg_id) {
+			ports.push({
+				id: data.reject_edg_id,
+				type: 'port',
+				label: '拒绝'
+			})
+		}
+		if (data?.cancel_edg_id) {
+			ports.push({
+				id: data.cancel_edg_id,
+				type: 'port',
+				label: '取消'
+			})
+		}
+		if (data?.other_edg_id) {
+			ports.push({
+				id: data.other_edg_id,
+				type: 'port',
+				label: '其他'
+			})
+		}
+
+		return ports
+	},
 	getSubtitle: (data: WorkflowApprovalData) => {
 		return data?.workflowCode ? `workflow: ${data.workflowCode}` : ''
 	},
@@ -30,15 +71,10 @@ export const workflowApprovalNode: INodeType = {
 			y: 30
 		},
 		width: 96,
-		height: 96,
+		height: 96 + 64,
 		selected: false,
 		nodeType: 'workflow-approval',
 		zIndex: 1,
-		data: {
-			usn: '用户账号/手机号/邮箱',
-			jobId: '岗位id',
-			workflowCode: '流程编号'
-		}
+		data: {}
 	}
 }
-

+ 3 - 0
apps/web/src/types/js-yaml.d.ts

@@ -0,0 +1,3 @@
+declare module 'js-yaml' {
+	export function load(yaml: string): unknown
+}

+ 10 - 4
apps/web/src/views/editor/NodeView.vue

@@ -623,8 +623,11 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
 			newNodeParam.position = position
 			newNodeParam.prevNodeId = handle?.nodeId
 			newNodeParam.parentId = parentId
-			// 包含下划线的需要传值
-			if (handle?.handleId?.includes('_')) {
+			// 包含下划线/纯UUID的需要传值
+			if (
+				handle?.handleId?.includes('_') ||
+				(handle?.handleId && !handle.handleId.includes('-source'))
+			) {
 				newNodeParam.nodeHandleId = handle?.handleId
 			}
 		}
@@ -752,8 +755,11 @@ const onCreateConnection = async (connection: Connection) => {
 		target,
 		zIndex: 1
 	}
-	// 包含下划线的需要传值
-	if (sourceHandle && sourceHandle.includes('_')) {
+	// 包含下划线或者无-source的需要传值
+	if (
+		(sourceHandle && sourceHandle.includes('_')) ||
+		(sourceHandle && !sourceHandle.includes('-source'))
+	) {
 		params.sourceHandle = sourceHandle
 	}
 

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

@@ -41,6 +41,18 @@ const warningInfo = computed(() => {
 	const validate = nodeType.value?.validate
 	return validate && validate(nodeData.value?.data)
 })
+
+const resolvedIcon = computed(() => {
+	const icon = `${nodeType.value?.icon || ''}`
+	if (!icon) return 'lucide:cloud'
+	if (icon.startsWith('data:image/')) return icon
+	if (icon.length > 64 && /^[A-Za-z0-9+/=]+$/.test(icon)) {
+		return `data:image/png;base64,${icon}`
+	}
+	return icon
+})
+
+const isImageIcon = computed(() => resolvedIcon.value.startsWith('data:image/'))
 </script>
 
 <template>
@@ -58,12 +70,13 @@ const warningInfo = computed(() => {
 				<div
 					class="absolute inset-0 bg-gradient-to-br from-white/30 to-transparent rounded-lg"
 				></div>
-				<Icon
-					:icon="nodeType?.icon ?? 'lucide:cloud'"
-					color="#ffffff"
-					class="relative z-10"
-					width="20"
+				<img
+					v-if="isImageIcon"
+					:src="resolvedIcon"
+					alt="node icon"
+					class="relative z-10 w-20px h-20px object-contain"
 				/>
+				<Icon v-else :icon="resolvedIcon" color="#ffffff" class="relative z-10" width="20" />
 			</div>
 		</div>
 

+ 18 - 5
packages/workflow/src/components/elements/nodes/render-types/NodeIcon.vue

@@ -21,6 +21,18 @@ const nodeData = computed(() => node.props.value.data)
 
 const nodeType = computed(() => nodeMap.value[nodeData.value?.nodeType!])
 const executionStatus = computed(() => getNodeExecutionStatus(node.props.value.node))
+
+const resolvedIcon = computed(() => {
+	const icon = `${nodeType.value?.icon || ''}`
+	if (!icon) return 'lucide:cloud'
+	if (icon.startsWith('data:image/')) return icon
+	if (icon.length > 64 && /^[A-Za-z0-9+/=]+$/.test(icon)) {
+		return `data:image/png;base64,${icon}`
+	}
+	return icon
+})
+
+const isImageIcon = computed(() => resolvedIcon.value.startsWith('data:image/'))
 </script>
 
 <template>
@@ -39,12 +51,13 @@ const executionStatus = computed(() => getNodeExecutionStatus(node.props.value.n
 					<div
 						class="absolute inset-0 bg-gradient-to-br from-white/30 to-transparent rounded-lg"
 					></div>
-					<Icon
-						:icon="nodeType?.icon ?? 'lucide:cloud'"
-						color="#ffffff"
-						class="relative z-10"
-						:size="20"
+					<img
+						v-if="isImageIcon"
+						:src="resolvedIcon"
+						alt="node icon"
+						class="relative z-10 w-20px h-20px object-contain"
 					/>
+					<Icon v-else :icon="resolvedIcon" color="#ffffff" class="relative z-10" :size="20" />
 				</div>
 			</div>
 		</div>

+ 17 - 1
packages/workflow/src/components/elements/nodes/render-types/NodeLoop.vue

@@ -58,6 +58,16 @@ const nodeType = computed(() => {
 	const type = nodeData.value?.nodeType
 	return type ? nodeMap.value[type] : undefined
 })
+const resolvedIcon = computed(() => {
+	const icon = `${nodeType.value?.icon || ''}`
+	if (!icon) return 'lucide:infinity'
+	if (icon.startsWith('data:image/')) return icon
+	if (icon.length > 64 && /^[A-Za-z0-9+/=]+$/.test(icon)) {
+		return `data:image/png;base64,${icon}`
+	}
+	return icon
+})
+const isImageIcon = computed(() => resolvedIcon.value.startsWith('data:image/'))
 const isReadOnly = computed(() => node.props.value.readOnly ?? false)
 
 const width = computed(() => Number(nodeData.value?.width) || 424)
@@ -128,7 +138,13 @@ function onAddNode() {
 				class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-4px"
 				:style="{ background: nodeType?.iconColor ?? '#06aed4' }"
 			>
-				<Icon :icon="nodeType?.icon ?? 'lucide:infinity'" color="#ffffff" :size="16" />
+				<img
+					v-if="isImageIcon"
+					:src="resolvedIcon"
+					alt="node icon"
+					class="w-16px h-16px object-contain"
+				/>
+				<Icon v-else :icon="resolvedIcon" color="#ffffff" :size="16" />
 			</div>
 			<span class="text-14px font-medium text-#333">{{
 				node.props.value.node?.name ?? '节点标题'