Browse Source

fix: 智能体接口联调/删除多余的axios封装

Mickey Mike 4 weeks ago
parent
commit
a69a983be0

+ 0 - 55
apps/web/src/api/agent.ts

@@ -1,55 +0,0 @@
-import type { AxiosRequestConfig } from 'axios'
-import { request } from '@/utils/request'
-import type { AgentInfo, BatchGenerateUUIDResult } from '@/api/types'
-
-const getEnterpriseCode = (): string => {
-	return new URLSearchParams(window.location.search).get('enterpriseCode') || ''
-}
-
-const getTokenKey = (): string => {
-	const enterpriseCode = getEnterpriseCode()
-	return enterpriseCode ? `token_${enterpriseCode}` : 'token'
-}
-
-const getAuthToken = (): string => {
-	return window.localStorage.getItem(getTokenKey()) || ''
-}
-
-const withAuthHeaders = (config: AxiosRequestConfig): AxiosRequestConfig => {
-	return {
-		...config,
-		headers: {
-			...(config.headers || {}),
-			Authorization: getAuthToken()
-		}
-	}
-}
-
-const withAgentPrefix = (url?: string): string => {
-	if (!url) return '/api/agent'
-	if (url.startsWith('/api/agent/')) return url
-	return `/api/agent${url.startsWith('/') ? '' : '/'}${url}`
-}
-
-export const agentRequest = async <T = any>(config: AxiosRequestConfig): Promise<T> => {
-	return request<T>(withAuthHeaders({ ...config, url: withAgentPrefix(config.url) }))
-}
-
-// 批量生成UUID
-export const doBatchGenerateUUID = async (data?: any): Promise<BatchGenerateUUIDResult> => {
-	return request<BatchGenerateUUIDResult>(
-		withAuthHeaders({
-			url: '/api/openapi/doBatchGenerateUUID',
-			method: 'post',
-			data
-		})
-	)
-}
-// 获取智能体
-export const getAgentInfo = async (id: string): Promise<AgentInfo> => {
-	return agentRequest<AgentInfo>({
-		url: '/getAgentInfo',
-		method: 'post',
-		data: { id }
-	})
-}

+ 0 - 50
apps/web/src/api/types.ts

@@ -1,50 +0,0 @@
-export type BatchGenerateUUIDResult = string[]
-
-export interface EnvVariable {
-	is_require: boolean
-	name: string
-	type: string
-	value: string
-}
-
-export interface AgentNodeOutput {
-	name: string
-	describe: string
-	type: string
-}
-
-export interface AgentNodeHead {
-	name: string
-	value: string
-}
-
-export interface AgentNode {
-	appAgentId: string
-	creationTime: string
-	creatorUserId: string
-	data: Record<string, any> & {
-		outputs?: AgentNodeOutput[]
-		heads?: AgentNodeHead[]
-		selected?: boolean
-	}
-	height: number
-	id: string
-	isDeleted: boolean
-	position: { x: number; y: number }
-	selected: boolean
-	type: string
-	updateTime: string
-	width: number
-	zIndex: number
-}
-
-export interface AgentInfo {
-	conversation_variables: any[]
-	edges: any[]
-	env_variables: EnvVariable[]
-	id: string
-	name: string
-	nodes: AgentNode[]
-	profilePhoto: string
-	viewPort: { x: number; y: number; zoom: number }
-}

+ 65 - 62
apps/web/src/components/RunWorkflow/index.vue

@@ -6,82 +6,85 @@
  * @Describe: 运行工作流
 -->
 <script lang="ts" setup>
-import { ElButton } from 'element-plus';
-import { Icon } from '@iconify/vue';
+import { ElButton } from 'element-plus'
+import { Icon } from '@iconify/vue'
 const props = withDefaults(
-    defineProps<{
-        visible: boolean,
-    }>(),
-    {
-        visible: false,
-    }
-);
+	defineProps<{
+		visible: boolean
+	}>(),
+	{
+		visible: false
+	}
+)
 const emit = defineEmits<{
-    'update:visible': [value: boolean]
+	'update:visible': [value: boolean]
+	run: []
 }>()
 </script>
 <template>
-    <div class='runWorkflow'>
-			<div class="drawer shadow-2xl" :class="{ 'drawer--open': props.visible }">
+	<div class="runWorkflow">
+		<div class="drawer shadow-2xl" :class="{ 'drawer--open': props.visible }">
+			<header>
+				<h4>运行工作流</h4>
+				<Icon
+					icon="lucide:x"
+					height="24"
+					width="24"
+					@click="emit('update:visible', false)"
+					class="cursor-pointer"
+				></Icon>
+			</header>
 
-            <header>
-                <h4>运行工作流</h4>
-								<Icon icon="lucide:x" height="24" width="24" @click="emit('update:visible', false)" class="cursor-pointer"></Icon>
-            </header>
+			<!-- Drawer content -->
+			<div class="content">在此处配置运行参数(如果有)。</div>
 
-            <!-- Drawer content -->
-            This is drawer content.
-
-            <footer>
-                <ElButton type="success" size="large" class="w-full" @click="emit('update:visible', false)">
-                    运行
-                </ElButton>
-            </footer>
-
-			</div>
-    </div>
+			<footer>
+				<ElButton type="success" size="large" class="w-full" @click="emit('run')"> 运行 </ElButton>
+			</footer>
+		</div>
+	</div>
 </template>
 <style lang="less" scoped>
 .runWorkflow {
 	/* Drawer 主体 */
-		.drawer {
-			position: fixed;
-			top: 100px;
-			right: 5px;
-			bottom: 10px;
-			width: 420px;
-			background: #fff;
-			z-index: 1000;
-			border-radius: 8px;
-			display: flex;
-			flex-direction: column;
-			border: 1px solid #e4e4e4;
+	.drawer {
+		position: fixed;
+		top: 100px;
+		right: 5px;
+		bottom: 10px;
+		width: 420px;
+		background: #fff;
+		z-index: 1000;
+		border-radius: 8px;
+		display: flex;
+		flex-direction: column;
+		border: 1px solid #e4e4e4;
 
-			/* 初始隐藏状态 */
-			transform: translateX(110%);
-			transition: transform 0.25s ease;
-		}
+		/* 初始隐藏状态 */
+		transform: translateX(110%);
+		transition: transform 0.25s ease;
+	}
 
-		/* 显示状态 */
-		.drawer--open {
-			transform: translateX(0);
-		}
+	/* 显示状态 */
+	.drawer--open {
+		transform: translateX(0);
+	}
 
-		/* Header */
-		.drawer header {
-			height: 56px;
-			padding: 0 16px;
-			border-bottom: 1px solid #eee;
-			display: flex;
-			align-items: center;
-			justify-content: space-between;
-		}
+	/* Header */
+	.drawer header {
+		height: 56px;
+		padding: 0 16px;
+		border-bottom: 1px solid #eee;
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+	}
 
-		/* 内容区 */
-		.drawer .content {
-			flex: 1;
-			padding: 16px;
-			overflow-y: auto;
-		}
+	/* 内容区 */
+	.drawer .content {
+		flex: 1;
+		padding: 16px;
+		overflow-y: auto;
+	}
 }
 </style>

+ 91 - 13
apps/web/src/components/setter/HttpSetter.vue

@@ -23,7 +23,7 @@ const emit = defineEmits<{
 	update: [data: unknown]
 }>()
 
-const DEFAULT_DATA = {
+const DEFAULT_DATA: Record<string, any> = {
 	method: 'GET',
 	url: '',
 	headers: [],
@@ -55,15 +55,100 @@ const DEFAULT_DATA = {
 	}
 }
 
-const formData = ref(clone(DEFAULT_DATA))
+const formData = ref<Record<string, any>>(clone(DEFAULT_DATA))
+const formDataItem = { key: '', value: '', type: 'text' }
+const defaultItem = { key: '', value: '' }
+const headers = ref([{ key: '', value: '' }])
+const params = ref([{ key: '', value: '' }])
+const body = ref<string | Record<string, any>[]>('')
+
+const normalizeBodyValue = (body: any, bodyType: string) => {
+	if (body == null) return ''
+
+	if (typeof body === 'string' || Array.isArray(body)) {
+		return body
+	}
+
+	if (typeof body === 'object' && 'type' in body && 'data' in body) {
+		const sourceType = body.type || bodyType
+		const sourceData = Array.isArray(body.data) ? body.data : []
+
+		if (['form-data', 'x-www-form-urlencoded'].includes(sourceType)) {
+			return sourceData.map((item: any) => ({
+				key: item?.key ?? '',
+				value: item?.value ?? '',
+				type: item?.type ?? 'text'
+			}))
+		}
+
+		if (sourceType === 'json' || sourceType === 'raw' || sourceType === 'binary') {
+			const value = sourceData?.[0]?.value
+			if (typeof value === 'string') {
+				return value
+			}
+			return value != null ? String(value) : ''
+		}
+
+		return ''
+	}
+
+	try {
+		return JSON.stringify(body, null, 2)
+	} catch {
+		return ''
+	}
+}
+
+const normalizeIncomingData = (raw: any) => {
+	const merged = {
+		...clone(DEFAULT_DATA),
+		...(raw || {})
+	}
+
+	const bodyType = merged.bodyType || raw?.body?.type || 'json'
+	const bodyValue = normalizeBodyValue(raw?.body ?? merged.body, bodyType)
+
+	return {
+		...merged,
+		method: (raw?.method || merged.method || 'GET').toUpperCase(),
+		bodyType,
+		body: bodyValue,
+		headers: raw?.headers || raw?.heads || merged.headers || [],
+		verifySSL: raw?.verifySSL ?? raw?.ssl_verify ?? merged.verifySSL,
+		timeoutConfig: {
+			connect: raw?.timeoutConfig?.connect ?? raw?.timeout_config?.max_connect_timeout ?? 8,
+			read: raw?.timeoutConfig?.read ?? raw?.timeout_config?.max_read_timeout ?? 6,
+			write: raw?.timeoutConfig?.write ?? raw?.timeout_config?.max_write_timeout ?? 1
+		},
+		errorConfig: {
+			retry: raw?.errorConfig?.retry ?? raw?.retry_config?.retry_enabled ?? true,
+			max_retry: raw?.errorConfig?.max_retry ?? raw?.retry_config?.max_retries ?? 3,
+			retry_delay: raw?.errorConfig?.retry_delay ?? raw?.retry_config?.retry_interval ?? 100
+		}
+	}
+}
 
 watch(
 	() => props.data,
 	(newVal) => {
-		if (!isEqual(newVal, formData.value)) {
-			formData.value = {
-				...clone(DEFAULT_DATA),
-				...(newVal || {})
+		const normalizedData = normalizeIncomingData(newVal)
+		if (!isEqual(normalizedData, formData.value)) {
+			formData.value = normalizedData
+
+			headers.value = formData.value.headers?.length
+				? [...formData.value.headers, { key: '', value: '' }]
+				: [{ key: '', value: '' }]
+
+			params.value = formData.value.params?.length
+				? [...formData.value.params, { key: '', value: '' }]
+				: [{ key: '', value: '' }]
+
+			if (['form-data', 'x-www-form-urlencoded'].includes(formData.value.bodyType)) {
+				const bodyArray = Array.isArray(formData.value.body) ? formData.value.body : []
+				const item = formData.value.bodyType === 'form-data' ? formDataItem : defaultItem
+				body.value = bodyArray.length ? [...bodyArray, { ...item }] : [{ ...item }]
+			} else {
+				body.value = typeof formData.value.body === 'string' ? formData.value.body : ''
 			}
 		}
 	},
@@ -96,10 +181,6 @@ const exceptionOptions = [
 	{ label: '异常分支', value: 'exception_branch' }
 ]
 
-const headers = ref([{ key: '', value: '' }])
-const params = ref([{ key: '', value: '' }])
-const body = ref<string | Record<string, any>[]>('')
-
 watch(
 	() => formData.value,
 	(value) => {
@@ -157,9 +238,6 @@ const handleDeleteParam = (index: number) => {
 	}
 }
 
-const formDataItem = { key: '', value: '', type: 'text' }
-const defaultItem = { key: '', value: '' }
-
 const handleChangeBodyType = (type: string) => {
 	if (['form-data', 'x-www-form-urlencoded'].includes(type)) {
 		const item = type === 'form-data' ? formDataItem : defaultItem

+ 0 - 121
apps/web/src/utils/request.ts

@@ -1,121 +0,0 @@
-import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
-import { ElMessage } from 'element-plus'
-
-export interface RequestOptions {
-	/** 当前接口权限, 不需要鉴权的接口请忽略 */
-	permCode?: string
-	/** 是否直接获取data,而忽略message等 */
-	isGetDataDirectly?: boolean
-	/** 请求成功时提示信息 */
-	successMsg?: string
-	/** 请求失败时提示信息 */
-	errorMsg?: string
-	/** 是否mock数据请求 */
-	isMock?: boolean
-}
-
-const UNKNOWN_ERROR = '未知错误,请重试'
-
-/** 真实请求的路径前缀 */
-// const baseApiUrl = import.meta.env.VITE_BASE_API
-/** mock请求路径前缀 */
-// const baseMockUrl = import.meta.env.VITE_MOCK_API
-
-export const service = axios.create({
-	timeout: 30000,
-	withCredentials: true
-})
-
-service.interceptors.request.use(
-	(config) => {
-		return config
-	},
-	(error) => Promise.reject(error)
-)
-
-service.interceptors.response.use(
-	(response: AxiosResponse<any>) => {
-		if (response.config.responseType === 'blob') {
-			return response
-		}
-
-		const res = response.data
-		if (res === null || res === undefined) {
-			return res
-		}
-		if (Array.isArray(res)) {
-			return res
-		}
-		if (typeof res !== 'object') {
-			return res
-		}
-
-		const code = (res as any).code
-		const isSuccess = (res as any).isSuccess
-		if (typeof code !== 'undefined' || typeof isSuccess !== 'undefined') {
-			const ok = code === 1 || code === 0 || isSuccess === true
-			if (!ok) {
-				const message = res.error || res.msg || res.message || UNKNOWN_ERROR
-				ElMessage.error(message)
-
-				if (code === 11001 || code === 11002) {
-					window.localStorage.clear()
-					window.location.reload()
-				}
-
-				const error = new Error(message) as Error & { code?: any }
-				error.code = code
-				return Promise.reject(error)
-			}
-		}
-
-		return res
-	},
-	(error) => {
-		const errMsg = error?.response?.data?.message ?? error?.message ?? UNKNOWN_ERROR
-		ElMessage.error(errMsg)
-		error.message = errMsg
-		return Promise.reject(error)
-	}
-)
-
-export type Response<T = any> = {
-	code: number
-	msg: string
-	isSuccess: boolean
-	isAuthorized: boolean
-	result: T
-}
-
-export type BaseResponse<T = any> = Promise<Response<T>>
-
-export const request = async <T = any>(
-	config: AxiosRequestConfig,
-	options: RequestOptions = {}
-): Promise<T> => {
-	try {
-		const { successMsg, permCode, isMock, isGetDataDirectly = true } = options
-		console.log(permCode, isMock)
-
-		const res = await service.request(config)
-
-		successMsg && ElMessage.success(successMsg)
-
-		if (!isGetDataDirectly) {
-			return res as T
-		}
-
-		if (res && typeof res === 'object' && 'result' in res) {
-			return (res as any).result as T
-		}
-		if (res && typeof res === 'object' && 'data' in res) {
-			return (res as any).data as T
-		}
-
-		return res as T
-	} catch (error: any) {
-		const { errorMsg } = options
-		errorMsg && ElMessage.error(errorMsg)
-		return Promise.reject(error)
-	}
-}

+ 447 - 29
apps/web/src/views/Editor.vue

@@ -55,15 +55,16 @@
 					@create:connection="onCreateConnection"
 					@drop="handleDrop"
 					@run="handleRunWorkflow"
+					@run:node="handleRunNode"
 					@update:nodes:position="handleUpdateNodesPosition"
 					@update:node:attrs="handleUpdateNodeProps"
 					@delete:node="handleDeleteNode"
 					@delete:connection="handleDeleteEdge"
 					class="bg-#f5f5f5"
 				>
-					<Toolbar @create:node="handleNodeCreate" />
+					<Toolbar @create:node="handleNodeCreate" @run="handleRunSelectedNode" />
 				</Workflow>
-				<RunWorkflow v-model:visible="runVisible" />
+				<RunWorkflow v-model:visible="runVisible" @run="handleRunSelectedNode" />
 				<Setter
 					:id="nodeID"
 					:workflow="workflow!"
@@ -80,7 +81,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, inject, type CSSProperties, onBeforeUnmount, watch } from 'vue'
+import { ref, inject, type CSSProperties, onBeforeUnmount, watch, nextTick } from 'vue'
 import { startNode, endNode, httpNode, conditionNode, databaseNode, codeNode } from '@repo/nodes'
 import { Workflow, type IWorkflow, type XYPosition, type Connection } from '@repo/workflow'
 import { v4 as uuid } from 'uuid'
@@ -95,7 +96,7 @@ import Toolbar from '@/features/toolbar/index.vue'
 import { IconButton, Input } from '@repo/ui'
 
 import type { SourceType } from '@repo/nodes'
-import { dayjs, ElMessageBox } from 'element-plus'
+import { dayjs, ElMessage, ElMessageBox } from 'element-plus'
 
 const layout = inject<{ setMainStyle: (style: CSSProperties) => void }>('layout')
 
@@ -103,10 +104,6 @@ layout?.setMainStyle({
 	padding: '0px'
 })
 
-agent.postGetAgentInfo({
-	id: 'b3a4aabb-a6b8-47f3-8a32-f45930f7d7b8'
-})
-
 const footerHeight = ref(32)
 const route = useRoute()
 const router = useRouter()
@@ -130,36 +127,349 @@ const workflow = ref<IWorkflow>(
 			}
 )
 const inputRef = ref<InstanceType<typeof Input>>()
+const saveAgentTimer = ref<number | undefined>(undefined)
+const saveVarsTimer = ref<number | undefined>(undefined)
+const isHydrating = ref(false)
+const notifyTimestamps = new Map<string, number>()
+
+const nodeTypeMap: Record<string, string> = {
+	'http-request': 'http-request',
+	'if-else': 'condition',
+	condition: 'condition',
+	code: 'code',
+	database: 'database',
+	start: 'start',
+	end: 'end'
+}
+
+const nodeSchemaMap: Record<string, any> = {
+	start: startNode.schema,
+	end: endNode.schema,
+	'http-request': httpNode.schema,
+	condition: conditionNode.schema,
+	code: codeNode.schema,
+	database: databaseNode.schema
+}
+
+const normalizeNodeType = (node: any) => {
+	const sourceNodeType = node?.nodeType || node?.data?.nodeType || node?.data?.type || node?.type
+	return nodeTypeMap[sourceNodeType] || sourceNodeType || 'code'
+}
+
+type AgentNodeType = 'custom' | 'start' | 'end' | 'condition' | 'task' | 'http-request'
+
+const toApiNodeType = (nodeType?: string): AgentNodeType => {
+	if (!nodeType) return 'custom'
+	return ['start', 'end', 'condition', 'http-request'].includes(nodeType)
+		? (nodeType as AgentNodeType)
+		: 'custom'
+}
+
+const toApiNodeData = (nodeData: any) => {
+	if (nodeData?.nodeType !== 'http-request') {
+		return { ...(nodeData || {}) }
+	}
+
+	const bodyType = nodeData?.bodyType || 'json'
+	const bodyValue = nodeData?.body
+	const bodyData = Array.isArray(bodyValue)
+		? bodyValue.map((item: any) => ({
+				key: item?.key ?? '',
+				value: item?.value ?? '',
+				type: item?.type ?? 'text'
+			}))
+		: [
+				{
+					key: '',
+					value: typeof bodyValue === 'string' ? bodyValue : '',
+					type: 'text'
+				}
+			]
+
+	return {
+		...(nodeData || {}),
+		method: (nodeData?.method || 'get').toLowerCase(),
+		ssl_verify: nodeData?.verifySSL ?? nodeData?.ssl_verify ?? true,
+		headers: undefined,
+		heads: (nodeData?.headers || []).map((item: any) => ({
+			name: item?.key ?? '',
+			value: item?.value ?? ''
+		})),
+		timeout_config: {
+			max_connect_timeout: nodeData?.timeoutConfig?.connect ?? 0,
+			max_read_timeout: nodeData?.timeoutConfig?.read ?? 0,
+			max_write_timeout: nodeData?.timeoutConfig?.write ?? 0
+		},
+		retry_config: {
+			max_retries: nodeData?.errorConfig?.max_retry ?? 3,
+			retry_enabled: nodeData?.errorConfig?.retry ?? false,
+			retry_interval: nodeData?.errorConfig?.retry_delay ?? 100
+		},
+		body: {
+			type: bodyType,
+			data: bodyData
+		}
+	}
+}
+
+const notifySuccess = (key: string, message: string, cooldown = 1500) => {
+	const now = Date.now()
+	const last = notifyTimestamps.get(key) || 0
+	if (now - last < cooldown) return
+
+	notifyTimestamps.set(key, now)
+	ElMessage.success(message)
+}
+
+const handleApiResult = (response: any, successMessage?: string, errorMessage?: string) => {
+	if (response?.isSuccess) {
+		if (successMessage) {
+			notifySuccess(successMessage, successMessage)
+		}
+		return true
+	}
+	if (errorMessage) {
+		ElMessage.error(errorMessage)
+	}
+	return false
+}
+
+const buildUpdateNodePayload = (node: any) => {
+	return {
+		id: node.id,
+		appAgentId: workflow.value.id,
+		parentId: node.parentId || node.data?.parentId || '',
+		position: node.position || { x: 20, y: 30 },
+		width: node.width ?? node.data?.width ?? 96,
+		height: node.height ?? node.data?.height ?? 96,
+		selected: !!node.selected,
+		nodeType: toApiNodeType(node.data?.nodeType || node.nodeType),
+		zIndex: node.zIndex ?? 1,
+		data: toApiNodeData(node.data)
+	}
+}
+
+const toWorkflowNode = (node: any) => {
+	const normalizedNodeType = normalizeNodeType(node)
+	const schema = nodeSchemaMap[normalizedNodeType] || codeNode.schema
+	const position = node?.position || schema.position || { x: 20, y: 30 }
+	const width = node?.width ?? schema.width ?? 96
+	const height = node?.height ?? schema.height ?? 96
+
+	return {
+		...schema,
+		...node,
+		id: node.id,
+		type: 'canvas-node',
+		position,
+		width,
+		height,
+		zIndex: node?.zIndex ?? schema.zIndex ?? 1,
+		selected: !!node?.selected,
+		data: {
+			...(schema.data || {}),
+			...(node?.data || {}),
+			id: node.id,
+			position,
+			width,
+			height,
+			nodeType: normalizedNodeType
+		}
+	}
+}
+
+const resolveOverlapPositions = (nodes: any[]) => {
+	const positionCountMap = new Map<string, number>()
+	const gapX = 220
+	const gapY = 140
+
+	return nodes.map((node) => {
+		const x = Number(node?.position?.x ?? 20)
+		const y = Number(node?.position?.y ?? 30)
+		const key = `${x},${y}`
+		const currentCount = positionCountMap.get(key) ?? 0
+		positionCountMap.set(key, currentCount + 1)
+
+		if (currentCount === 0) {
+			return node
+		}
+
+		const row = Math.floor(currentCount / 4)
+		const col = currentCount % 4
+		const nextPosition = {
+			x: x + col * gapX,
+			y: y + row * gapY
+		}
+
+		return {
+			...node,
+			position: nextPosition,
+			data: {
+				...(node?.data || {}),
+				position: nextPosition
+			}
+		}
+	})
+}
+
+const toWorkflowEdge = (edge: any, index: number) => {
+	if (!edge || typeof edge !== 'object' || !edge.source || !edge.target) {
+		return null
+	}
+
+	return {
+		...edge,
+		id: edge.id || `edge-${edge.source}-${edge.target}-${index}`,
+		type: 'canvas-edge',
+		data: edge.data || {}
+	}
+}
+
+const isPendingCreate = (node: any) => {
+	return !!(node as any)?.__pendingCreate
+}
+
+const isEqualNodeData = (current: any, next: any) => {
+	try {
+		return JSON.stringify(current ?? {}) === JSON.stringify(next ?? {})
+	} catch {
+		return current === next
+	}
+}
+
+const loadAgentWorkflow = async (agentId: string) => {
+	if (!agentId) return
+	isHydrating.value = true
+
+	try {
+		const response = await agent.postGetAgentInfo({ id: agentId })
+		const result = response?.result
+		if (!response?.isSuccess || !result) {
+			throw new Error('获取智能体信息失败')
+		}
+
+		const mappedNodes = (result.nodes || []).map(toWorkflowNode)
+		const positionedNodes = resolveOverlapPositions(mappedNodes)
+
+		workflow.value = {
+			id: result.id || agentId,
+			name: result.name || 'workflow_1',
+			created: dayjs().format('MM 月 DD 日'),
+			nodes: positionedNodes,
+			edges: (result.edges || []).map(toWorkflowEdge).filter(Boolean),
+			tags: workflow.value?.tags || [],
+			conversation_variables: result.conversation_variables || [],
+			env_variables: result.env_variables || [],
+			profilePhoto: result.profilePhoto,
+			viewPort: result.viewPort
+		}
+		await nextTick()
+	} catch (error) {
+		console.error('loadAgentWorkflow error', error)
+		ElMessage.error('加载智能体流程失败')
+	} finally {
+		isHydrating.value = false
+	}
+}
+
+const saveAgentMeta = async () => {
+	if (!workflow.value?.id) return
+
+	try {
+		const response = await agent.postDoEditAgent({
+			data: {
+				id: workflow.value.id,
+				name: workflow.value.name,
+				tags: workflow.value.tags || [],
+				description: workflow.value.description || '',
+				remark: workflow.value.description || '',
+				profilePhoto: workflow.value.profilePhoto,
+				viewPort: workflow.value.viewPort
+			}
+		})
+		handleApiResult(response, '智能体已保存', '保存智能体失败')
+	} catch (error) {
+		console.error('saveAgentMeta error', error)
+		ElMessage.error('保存智能体失败')
+	}
+}
+
+const saveAgentVariables = async () => {
+	if (!workflow.value?.id) return
+
+	try {
+		const response = await agent.postDoSaveAgentVariables({
+			appAgentId: workflow.value.id,
+			conversation_variables: workflow.value.conversation_variables || [],
+			env_variables: (workflow.value.env_variables || []).map((item: any) => ({
+				name: item?.name || '',
+				value: item?.value ?? '',
+				type: item?.type || 'string'
+			}))
+		})
+		handleApiResult(response, '变量已保存', '保存变量失败')
+	} catch (error) {
+		console.error('saveAgentVariables error', error)
+		ElMessage.error('保存变量失败')
+	}
+}
+
+const scheduleSaveAgentMeta = () => {
+	if (saveAgentTimer.value) window.clearTimeout(saveAgentTimer.value)
+	if (!workflow.value?.id) return
+
+	saveAgentTimer.value = window.setTimeout(() => {
+		saveAgentMeta()
+	}, 600)
+}
+
+const scheduleSaveAgentVariables = () => {
+	if (saveVarsTimer.value) window.clearTimeout(saveVarsTimer.value)
+	if (!workflow.value?.id) return
+
+	saveVarsTimer.value = window.setTimeout(() => {
+		saveAgentVariables()
+	}, 600)
+}
 
 watch(
 	() => workflow.value,
 	(workflow) => {
-		projectMap[id] = workflow
+		projectMap[workflow.id] = workflow
 		localStorage.setItem(`workflow-map`, JSON.stringify(projectMap))
 	},
 	{ deep: true }
 )
 
+watch(
+	() => [workflow.value?.name, workflow.value?.description, workflow.value?.tags],
+	() => {
+		if (isHydrating.value) return
+		scheduleSaveAgentMeta()
+	},
+	{ deep: true }
+)
+
+watch(
+	() => [workflow.value?.conversation_variables, workflow.value?.env_variables],
+	() => {
+		if (isHydrating.value) return
+		scheduleSaveAgentVariables()
+	},
+	{ deep: true }
+)
+
 /**
  * 监听路由参数变化
  */
 watch(
 	() => route.params?.id,
-	(newId) => {
+	async (newId) => {
 		if (newId) {
-			const projectMap = JSON.parse(localStorage.getItem(`workflow-map`) || '{}') as Record<
-				string,
-				IWorkflow
-			>
-			workflow.value = projectMap[newId as string] ?? {
-				id: newId as string,
-				name: 'workflow_1',
-				created: dayjs().format('MM 月 DD 日'),
-				nodes: [],
-				edges: []
-			}
+			await loadAgentWorkflow('b3a4aabb-a6b8-47f3-8a32-f45930f7d7b8' as string)
 		}
-	}
+	},
+	{ immediate: true }
 )
 /**
  * Editor
@@ -174,9 +484,59 @@ const handleFooterToggle = (open: boolean) => {
 const nodeID = ref('')
 const setterVisible = ref(false)
 const runVisible = ref(false)
+const pendingSetterInit = new Set<string>()
 const handleRunWorkflow = () => {
 	runVisible.value = true
 }
+
+const handleRunSelectedNode = async () => {
+	if (!workflow.value?.id) {
+		ElMessage.warning('请先选择需要运行的节点')
+		return
+	}
+
+	if (!nodeID.value) {
+		const selectedNode = workflow.value?.nodes?.find((node) => (node as any)?.selected)
+		if (selectedNode?.id) {
+			nodeID.value = selectedNode.id
+		}
+	}
+
+	if (!nodeID.value) {
+		ElMessage.warning('请选择需要测试的节点')
+		return
+	}
+
+	try {
+		const response = await agent.postDoTestNodeRunner({
+			appAgentId: workflow.value.id,
+			nodeIds: [nodeID.value]
+		})
+		runVisible.value = false
+		handleApiResult(response, '已提交节点测试', '节点测试失败')
+	} catch (error) {
+		console.error('postDoTestNodeRunner error', error)
+		ElMessage.error('节点测试失败')
+	}
+}
+
+const handleRunNode = async (id: string) => {
+	if (!workflow.value?.id) {
+		ElMessage.warning('请先选择需要运行的节点')
+		return
+	}
+
+	try {
+		const response = await agent.postDoTestNodeRunner({
+			appAgentId: workflow.value.id,
+			nodeIds: [id]
+		})
+		handleApiResult(response, '已提交节点测试', '节点测试失败')
+	} catch (error) {
+		console.error('postDoTestNodeRunner error', error)
+		ElMessage.error('节点测试失败')
+	}
+}
 const handleNodeCreate = (value: SourceType | string) => {
 	const id = uuid()
 	if (typeof value === 'string') {
@@ -215,20 +575,44 @@ const handleNodeCreate = (value: SourceType | string) => {
 
 	// 如果存在对应节点则添加
 	if (nodeToAdd) {
-		workflow.value?.nodes.push({
+		const newNode = {
 			...nodeToAdd,
 			type: 'canvas-node',
 			data: {
 				...nodeToAdd,
 				id
 			},
+			__pendingCreate: true,
 			id
-		})
+		}
+		workflow.value?.nodes.push(newNode)
+
+		agent
+			.postDoNewAgentNode({
+				appAgentId: workflow.value.id,
+				position: newNode.position,
+				width: newNode.width,
+				height: newNode.height,
+				selected: !!newNode.selected,
+				nodeType: toApiNodeType(newNode.data?.nodeType),
+				zIndex: newNode.zIndex ?? 1,
+				parentId: newNode.parentId || ''
+			})
+			.then(async (response) => {
+				if (handleApiResult(response, '节点已添加', '新增节点失败')) {
+					await loadAgentWorkflow(workflow.value.id)
+				}
+			})
+			.catch((error) => {
+				console.error('postDoNewAgentNode error', error)
+				ElMessage.error('新增节点失败')
+			})
 	}
 	console.log(workflow.value?.nodes, 'workflow.nodes')
 }
-const handleNodeClick = (id: string, position: XYPosition) => {
+const handleNodeClick = (id: string, _position: XYPosition) => {
 	nodeID.value = id
+	pendingSetterInit.add(id)
 	setterVisible.value = true
 }
 
@@ -282,8 +666,19 @@ const onCreateConnection = (connection: Connection) => {
 const handleUpdateNodesPosition = (events: { id: string; position: XYPosition }[]) => {
 	events?.forEach(({ id, position }) => {
 		const node = workflow.value?.nodes.find((node) => node.id === id)
-		if (node) {
+		if (node && !isPendingCreate(node)) {
+			if (node.position?.x === position.x && node.position?.y === position.y) {
+				return
+			}
 			node.position = position
+			agent
+				.postDoUpdateAgentNode(buildUpdateNodePayload(node))
+				.then((response) => {
+					handleApiResult(response, undefined, '更新节点失败')
+				})
+				.catch((error) => {
+					console.error('postDoUpdateAgentNode error', error)
+				})
 		}
 	})
 }
@@ -293,11 +688,27 @@ const handleUpdateNodesPosition = (events: { id: string; position: XYPosition }[
  */
 const hangleUpdateNodeData = (id: string, data: any) => {
 	const node = workflow.value?.nodes.find((node) => node.id === id)
-	if (node) {
-		node.data = {
+	if (node && !isPendingCreate(node)) {
+		if (pendingSetterInit.has(id)) {
+			pendingSetterInit.delete(id)
+			return
+		}
+		const nextData = {
 			...node.data,
 			...data
 		}
+		if (isEqualNodeData(node.data, nextData)) {
+			return
+		}
+		node.data = nextData
+		agent
+			.postDoUpdateAgentNode(buildUpdateNodePayload(node))
+			.then((response) => {
+				handleApiResult(response, undefined, '更新节点失败')
+			})
+			.catch((error) => {
+				console.error('postDoUpdateAgentNode error', error)
+			})
 	}
 	console.log('hangleUpdateNodeData', id, data)
 }
@@ -307,7 +718,12 @@ const hangleUpdateNodeData = (id: string, data: any) => {
  */
 const handleUpdateNodeProps = (id: string, attrs: Record<string, unknown>) => {
 	const node = workflow.value?.nodes.find((node) => node.id === id)
-	if (node) {
+	if (node && !isPendingCreate(node)) {
+		const keys = Object.keys(attrs || {})
+		const meaningfulKeys = keys.filter((key) => !['selected', 'dragging'].includes(key))
+		if (meaningfulKeys.length === 0) {
+			return
+		}
 		if (node.data?.nodeType === 'stickyNote') {
 			Object.assign(node.data, attrs)
 		} else {
@@ -341,6 +757,8 @@ const handleDeleteEdge = (connection: Connection) => {
 }
 
 onBeforeUnmount(() => {
+	if (saveAgentTimer.value) window.clearTimeout(saveAgentTimer.value)
+	if (saveVarsTimer.value) window.clearTimeout(saveVarsTimer.value)
 	layout?.setMainStyle({})
 })
 </script>

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

@@ -284,6 +284,10 @@ function onDeleteNode(id: string) {
 	emit('delete:node', id)
 }
 
+function onRunNode(id: string) {
+	emit('run:node', id)
+}
+
 let loaded = false
 function onNodesInitialized() {
 	if (!loaded) {
@@ -345,6 +349,7 @@ defineExpose({
 					@move="onUpdateNodePosition"
 					@update="onUpdateNodeAttrs"
 					@delete="onDeleteNode"
+					@run="onRunNode"
 				/>
 			</slot>
 		</template>

+ 6 - 1
packages/workflow/src/components/elements/nodes/CanvasNode.vue

@@ -25,6 +25,7 @@ const emit = defineEmits<{
 	update: [id: string, parameters: Record<string, unknown>]
 	move: [id: string, position: { x: number; y: number }]
 	delete: [id: string]
+	run: [id: string]
 }>()
 
 /**
@@ -94,6 +95,10 @@ const onDelete = () => {
 	emit('delete', props.id)
 }
 
+const onRun = () => {
+	emit('run', props.id)
+}
+
 provide('canvas-node-data', {
 	props,
 	inputs,
@@ -113,6 +118,6 @@ provide('canvas-node-data', {
 			<CanvasHandle v-bind="source" type="source" />
 		</template>
 
-		<CanvasNodeToolBar @delete="onDelete" />
+		<CanvasNodeToolBar @delete="onDelete" @run="onRun" />
 	</div>
 </template>

+ 4 - 1
packages/workflow/src/components/elements/nodes/CanvasNodeToolBar.vue

@@ -8,6 +8,7 @@ import type { NodeProps } from '@vue-flow/core'
 
 const emit = defineEmits<{
 	delete: []
+	run: []
 }>()
 
 const node = inject<{
@@ -26,7 +27,9 @@ const more = () => {
 const BarHandleClick = (state: string) => {
 	console.log(state)
 	barState.value = false
-	if (state === 'node-edit') {
+	if (state === 'node-run') {
+		emit('run')
+	} else if (state === 'node-edit') {
 	}
 }