Editor.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. <template>
  2. <div class="w-full h-full flex flex-col">
  3. <div
  4. class="h-60px shrink-0 border-b border-b-solid border-gray-200 flex items-center justify-between px-12px"
  5. >
  6. <div class="left flex items-center gap-4">
  7. <el-breadcrumb separator="/" class="flex items-center">
  8. <el-breadcrumb-item>{{ t('pages.editor.workspace') }}</el-breadcrumb-item>
  9. <el-breadcrumb-item>
  10. <Input ref="inputRef" v-model="workflow.name" variant="borderless" />
  11. </el-breadcrumb-item>
  12. </el-breadcrumb>
  13. <div class="flex gap-2" v-show="!showTagInput" @click="showTagInput = true">
  14. <el-tag type="info" v-for="tag in workflow.tags" :key="tag" :disable-transitions="false">
  15. {{ tag }}
  16. </el-tag>
  17. </div>
  18. <el-input-tag
  19. v-show="showTagInput"
  20. v-model="workflow.tags"
  21. :placeholder="t('pages.editor.tagPlaceholder')"
  22. :aria-label="t('pages.editor.tagPlaceholder')"
  23. :max="5"
  24. @blur="showTagInput = false"
  25. />
  26. <IconButton
  27. v-if="!workflow.tags?.length && !showTagInput"
  28. icon="iconoir:plus"
  29. type="primary"
  30. link
  31. @click="showTagInput = true"
  32. >
  33. {{ t('pages.editor.tagButton') }}
  34. </IconButton>
  35. </div>
  36. <div class="right flex items-center gap-2">
  37. <PublishBtn :workflow="workflow" @published="handlePublished" />
  38. <IconButton icon="lucide:history" type="default" link></IconButton>
  39. <el-dropdown placement="bottom-end" popper-class="w-120px">
  40. <IconButton icon="fluent-mdl2:more" type="default" link></IconButton>
  41. <template #dropdown>
  42. <el-dropdown-item>{{ t('pages.editor.menu.description') }}</el-dropdown-item>
  43. <el-dropdown-item>{{ t('pages.editor.menu.reuse') }}</el-dropdown-item>
  44. <el-dropdown-item @click="handleRename">{{
  45. t('pages.editor.menu.rename')
  46. }}</el-dropdown-item>
  47. <el-dropdown-item divided @click="handleDelete">{{
  48. t('pages.editor.menu.delete')
  49. }}</el-dropdown-item>
  50. </template>
  51. </el-dropdown>
  52. </div>
  53. </div>
  54. <el-splitter layout="vertical" class="flex-1">
  55. <el-splitter-panel>
  56. <NodeView :key="workflow.id" :workflow="workflow" :reload-workflow="loadAgentWorkflow" />
  57. </el-splitter-panel>
  58. <el-splitter-panel v-model:size.lazy="footerHeight" :min="32">
  59. <EditorFooter @toggle="handleFooterToggle" />
  60. </el-splitter-panel>
  61. </el-splitter>
  62. </div>
  63. </template>
  64. <script setup lang="ts">
  65. import { inject, nextTick, onBeforeUnmount, ref, type CSSProperties, watch } from 'vue'
  66. import { useRoute, useRouter } from 'vue-router'
  67. import { dayjs, ElMessage, ElMessageBox } from 'element-plus'
  68. import { agent } from '@repo/api-service'
  69. import EditorFooter from '@/features/editorFooter/index.vue'
  70. import NodeView from './NodeView.vue'
  71. import PublishBtn from './PublishBtn.vue'
  72. import { nodeMap } from '@/nodes'
  73. import { IconButton, Input } from '@repo/ui'
  74. import { useI18n } from '@/composables/useI18n'
  75. import type { IWorkflow } from '@repo/workflow'
  76. const layout = inject<{ setMainStyle: (style: CSSProperties) => void }>('layout')
  77. layout?.setMainStyle({
  78. padding: '0px'
  79. })
  80. const route = useRoute()
  81. const router = useRouter()
  82. const { t } = useI18n()
  83. const footerHeight = ref(32)
  84. const showTagInput = ref(false)
  85. const inputRef = ref<InstanceType<typeof Input>>()
  86. const saveAgentTimer = ref<number | undefined>(undefined)
  87. const saveVarsTimer = ref<number | undefined>(undefined)
  88. const isHydrating = ref(false)
  89. const notifyTimestamps = new Map<string, number>()
  90. const id = route.params?.id as string
  91. const projectMap = JSON.parse(localStorage.getItem('workflow-map') || '{}') as Record<
  92. string,
  93. IWorkflow
  94. >
  95. const workflow = ref<IWorkflow>(
  96. projectMap?.[id]
  97. ? projectMap[id]
  98. : {
  99. id,
  100. name: 'workflow_1',
  101. created: dayjs().format('MM 月DD 日'),
  102. nodes: [],
  103. edges: []
  104. }
  105. )
  106. // 从后端节点数据中提取当前画布需要的节点类型。
  107. const normalizeNodeType = (node: any) => {
  108. const sourceNodeType = node?.nodeType || node?.data?.nodeType || node?.data?.type || node?.type
  109. return sourceNodeType || 'code'
  110. }
  111. // 将原始节点数据转换为画布组件可直接消费的节点结构。
  112. const toWorkflowNode = (node: any) => {
  113. const normalizedNodeType = normalizeNodeType(node)
  114. const schema = nodeMap[normalizedNodeType]?.schema
  115. const position = node?.position || schema?.position || { x: 20, y: 30 }
  116. const width = node?.width ?? schema?.width ?? 96
  117. const height = node?.height ?? schema?.height ?? 96
  118. return {
  119. ...schema,
  120. ...node,
  121. id: node.id,
  122. type: 'canvas-node',
  123. position,
  124. width,
  125. height,
  126. zIndex: node?.zIndex ?? schema?.zIndex ?? 1,
  127. selected: false,
  128. data: {
  129. ...(schema?.data || {}),
  130. ...(node?.data || {}),
  131. id: node.id,
  132. position,
  133. width,
  134. height,
  135. nodeType: normalizedNodeType
  136. }
  137. }
  138. }
  139. // 将跨循环作用域的边重定向到父节点,确保外层画布能正确渲染。
  140. const normalizeEdgeEndpoints = (
  141. edge: { source: string; target: string },
  142. nodes: Array<{ id: string; parentId?: string }>
  143. ) => {
  144. const sourceNode = nodes.find((node) => node.id === edge.source)
  145. const targetNode = nodes.find((node) => node.id === edge.target)
  146. const sourceParentId = sourceNode?.parentId || ''
  147. const targetParentId = targetNode?.parentId || ''
  148. if (sourceParentId && sourceParentId !== targetParentId) {
  149. return {
  150. source: sourceParentId,
  151. target: edge.target
  152. }
  153. }
  154. if (targetParentId && targetParentId !== sourceParentId) {
  155. return {
  156. source: edge.source,
  157. target: targetParentId
  158. }
  159. }
  160. return edge
  161. }
  162. // 为边补齐兜底 id 和 handle,避免画布渲染时缺少必要字段。
  163. const toWorkflowEdge = (
  164. edge: any,
  165. index: number,
  166. nodes: Array<{ id: string; parentId?: string }> = []
  167. ) => {
  168. if (!edge || typeof edge !== 'object' || !edge.source || !edge.target) {
  169. return null
  170. }
  171. const normalizedEdge = normalizeEdgeEndpoints(edge, nodes)
  172. return {
  173. ...edge,
  174. ...normalizedEdge,
  175. sourceHandle: edge.sourceHandle === 'source' ? `${edge.source}-source` : edge.sourceHandle,
  176. targetHandle: edge.targetHandle === 'target' ? `${edge.target}-target` : edge.targetHandle,
  177. id: edge.id || `edge-${normalizedEdge.source}-${normalizedEdge.target}-${index}`,
  178. type: 'canvas-edge',
  179. data: edge.data || {}
  180. }
  181. }
  182. // 自动保存频繁触发时,避免重复弹出相同成功提示。
  183. const notifySuccess = (key: string, message: string, cooldown = 1500) => {
  184. const now = Date.now()
  185. const last = notifyTimestamps.get(key) || 0
  186. if (now - last < cooldown) return
  187. notifyTimestamps.set(key, now)
  188. ElMessage.success(message)
  189. }
  190. // 统一处理编辑页保存类接口的成功与失败提示。
  191. const handleApiResult = (response: any, successMessage?: string, errorMessage?: string) => {
  192. if (response?.isSuccess) {
  193. if (successMessage) {
  194. notifySuccess(successMessage, successMessage)
  195. }
  196. return true
  197. }
  198. if (response?.code === 0 && response?.error) {
  199. ElMessage.error(response.error)
  200. return false
  201. }
  202. if (errorMessage) {
  203. ElMessage.error(errorMessage)
  204. }
  205. return false
  206. }
  207. // 画布依赖归一化后的节点和边结构,因此在页面入口统一完成数据适配。
  208. const loadAgentWorkflow = async (agentId: string) => {
  209. if (!agentId) return
  210. isHydrating.value = true
  211. try {
  212. const response = await agent.postAgentGetAgentInfo({ id: agentId })
  213. const result = response?.result
  214. if (!response?.isSuccess || !result) {
  215. throw new Error('获取智能体信息失败')
  216. }
  217. const normalizedNodes = (result.nodes || []).map(toWorkflowNode)
  218. const normalizedEdges = (result.edges || [])
  219. .map((edge: any, index: number) => toWorkflowEdge(edge, index, normalizedNodes))
  220. .filter(Boolean)
  221. workflow.value = {
  222. ...(result as unknown as IWorkflow),
  223. nodes: normalizedNodes,
  224. edges: normalizedEdges as IWorkflow['edges']
  225. }
  226. await nextTick()
  227. } catch (error) {
  228. console.error('loadAgentWorkflow error', error)
  229. ElMessage.error(t('pages.editor.messages.loadFailed'))
  230. } finally {
  231. isHydrating.value = false
  232. }
  233. }
  234. // 保存页面头部编辑的工作流基础信息。
  235. const saveAgentMeta = async () => {
  236. if (!workflow.value?.id) return
  237. try {
  238. const response = await agent.postAgentDoEditAgent({
  239. data: workflow.value
  240. })
  241. handleApiResult(
  242. response,
  243. t('pages.editor.messages.saved'),
  244. t('pages.editor.messages.saveFailed')
  245. )
  246. } catch (error) {
  247. console.error('saveAgentMeta error', error)
  248. ElMessage.error(t('pages.editor.messages.saveFailed'))
  249. }
  250. }
  251. // 保存不在节点画布内编辑的工作流变量。
  252. const saveAgentVariables = async () => {
  253. if (!workflow.value?.id) return
  254. try {
  255. const response = await agent.postAgentDoSaveAgentVariables({
  256. appAgentId: workflow.value.id,
  257. conversation_variables: workflow.value.conversation_variables || [],
  258. env_variables: (workflow.value.env_variables || []).map((item: any) => ({
  259. name: item?.name || '',
  260. value: item?.value ?? '',
  261. type: item?.type || 'string'
  262. }))
  263. })
  264. handleApiResult(
  265. response,
  266. t('pages.editor.messages.varsSaved'),
  267. t('pages.editor.messages.saveFailed')
  268. )
  269. } catch (error) {
  270. console.error('saveAgentVariables error', error)
  271. ElMessage.error(t('pages.editor.messages.saveFailed'))
  272. }
  273. }
  274. // 为基础信息保存增加防抖,减少输入过程中的重复请求。
  275. const scheduleSaveAgentMeta = () => {
  276. if (saveAgentTimer.value) window.clearTimeout(saveAgentTimer.value)
  277. if (!workflow.value?.id) return
  278. saveAgentTimer.value = window.setTimeout(() => {
  279. saveAgentMeta()
  280. }, 600)
  281. }
  282. // 为变量保存增加防抖,避免短时间内连续提交。
  283. const scheduleSaveAgentVariables = () => {
  284. if (saveVarsTimer.value) window.clearTimeout(saveVarsTimer.value)
  285. if (!workflow.value?.id) return
  286. saveVarsTimer.value = window.setTimeout(() => {
  287. saveAgentVariables()
  288. }, 600)
  289. }
  290. watch(
  291. () => workflow.value,
  292. (currentWorkflow) => {
  293. projectMap[currentWorkflow.id] = currentWorkflow
  294. localStorage.setItem('workflow-map', JSON.stringify(projectMap))
  295. },
  296. { deep: true }
  297. )
  298. watch(
  299. () => [workflow.value?.name, workflow.value?.description, workflow.value?.tags],
  300. () => {
  301. if (isHydrating.value) return
  302. scheduleSaveAgentMeta()
  303. },
  304. { deep: true }
  305. )
  306. watch(
  307. () => [workflow.value?.conversation_variables, workflow.value?.env_variables],
  308. () => {
  309. if (isHydrating.value) return
  310. scheduleSaveAgentVariables()
  311. },
  312. { deep: true }
  313. )
  314. watch(
  315. () => route.params?.id,
  316. async (nextId) => {
  317. if (nextId) {
  318. await loadAgentWorkflow(nextId as string)
  319. }
  320. },
  321. { immediate: true }
  322. )
  323. // 根据底部面板开关状态调整面板高度。
  324. const handleFooterToggle = (open: boolean) => {
  325. footerHeight.value = open ? 200 : 32
  326. }
  327. // 聚焦标题输入框,方便直接重命名当前工作流。
  328. const handleRename = () => {
  329. inputRef.value?.focus()
  330. inputRef.value?.select()
  331. }
  332. // 删除本地缓存中的当前工作流并退出编辑页。
  333. const handleDelete = () => {
  334. ElMessageBox.confirm(t('common.confirmDelete.message'), t('common.confirmDelete.title'), {
  335. confirmButtonText: t('common.confirm'),
  336. cancelButtonText: t('common.cancel'),
  337. type: 'warning'
  338. }).then(() => {
  339. localStorage.removeItem(`project_${id}`)
  340. router.push('/')
  341. })
  342. }
  343. const handlePublished = async () => {
  344. if (!workflow.value?.id) return
  345. await loadAgentWorkflow(workflow.value.id)
  346. }
  347. onBeforeUnmount(() => {
  348. if (saveAgentTimer.value) window.clearTimeout(saveAgentTimer.value)
  349. if (saveVarsTimer.value) window.clearTimeout(saveVarsTimer.value)
  350. layout?.setMainStyle({})
  351. })
  352. </script>