vue-agent-start 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/package.json +30 -0
  2. package/pnpm-workspace.yaml +3 -0
  3. package/src/agent-flow/adapter/backend.ts +99 -0
  4. package/src/agent-flow/components/ChatIframePanel.vue +773 -0
  5. package/src/agent-flow/components/DatasetItemCard.vue +313 -0
  6. package/src/agent-flow/components/MemoryWindow.vue +104 -0
  7. package/src/agent-flow/components/PromptEditor.vue +804 -0
  8. package/src/agent-flow/components/PromptEditorTagPanel.vue +460 -0
  9. package/src/agent-flow/components/SvgIcon.vue +32 -0
  10. package/src/agent-flow/components/ToolItemCard.vue +169 -0
  11. package/src/agent-flow/components/VariableCard.vue +161 -0
  12. package/src/agent-flow/components/VariableSelector.vue +162 -0
  13. package/src/agent-flow/components/chat-iframe-types.ts +35 -0
  14. package/src/agent-flow/components/stubs/DatasetChooser.vue +70 -0
  15. package/src/agent-flow/components/stubs/Embedding.vue +22 -0
  16. package/src/agent-flow/components/stubs/ModelSelect.vue +24 -0
  17. package/src/agent-flow/components/stubs/RestfulSelectCard.vue +24 -0
  18. package/src/agent-flow/components/stubs/ToolChooser.vue +96 -0
  19. package/src/agent-flow/components/stubs/TriggersItemCard.vue +126 -0
  20. package/src/agent-flow/components/stubs/VariableModal.vue +115 -0
  21. package/src/agent-flow/index.ts +73 -0
  22. package/src/agent-flow/stores/workflow.ts +72 -0
  23. package/src/agent-flow/utils/langUtils.ts +173 -0
  24. package/src/agent-flow/workflow/Ability.vue +65 -0
  25. package/src/agent-flow/workflow/AuthorizationItem.vue +101 -0
  26. package/src/agent-flow/workflow/ConversationVar.vue +42 -0
  27. package/src/agent-flow/workflow/CustomEdge.vue +94 -0
  28. package/src/agent-flow/workflow/EnvironmentVar.vue +40 -0
  29. package/src/agent-flow/workflow/FlowDesigner.vue +2802 -0
  30. package/src/agent-flow/workflow/Icon.vue +179 -0
  31. package/src/agent-flow/workflow/NodeConfig.vue +509 -0
  32. package/src/agent-flow/workflow/NodeConfigCard.vue +460 -0
  33. package/src/agent-flow/workflow/NodeHandle.vue +147 -0
  34. package/src/agent-flow/workflow/NodeHeaderToolbar.vue +77 -0
  35. package/src/agent-flow/workflow/NodeHoverToolbar.vue +176 -0
  36. package/src/agent-flow/workflow/OutputItemCard.vue +668 -0
  37. package/src/agent-flow/workflow/ParamTableItem.vue +200 -0
  38. package/src/agent-flow/workflow/StructTable.vue +202 -0
  39. package/src/agent-flow/workflow/VarInsertField.vue +517 -0
  40. package/src/agent-flow/workflow/VarSelectField.vue +223 -0
  41. package/src/agent-flow/workflow/VariableSelectorItem.vue +48 -0
  42. package/src/agent-flow/workflow/WfField.vue +206 -0
  43. package/src/agent-flow/workflow/_config-base.css +470 -0
  44. package/src/agent-flow/workflow/composables/useNode.js +55 -0
  45. package/src/agent-flow/workflow/index.vue +53 -0
  46. package/src/agent-flow/workflow/nodeCard/AgentNodeCard.vue +180 -0
  47. package/src/agent-flow/workflow/nodeCard/AnswerNodeCard.vue +65 -0
  48. package/src/agent-flow/workflow/nodeCard/ClassifierCard.vue +152 -0
  49. package/src/agent-flow/workflow/nodeCard/CodeNodeCard.vue +139 -0
  50. package/src/agent-flow/workflow/nodeCard/ConditionNodeCard.vue +323 -0
  51. package/src/agent-flow/workflow/nodeCard/DocumentExtractorCard.vue +83 -0
  52. package/src/agent-flow/workflow/nodeCard/EndNodeCard.vue +123 -0
  53. package/src/agent-flow/workflow/nodeCard/HttpNodeCard.vue +451 -0
  54. package/src/agent-flow/workflow/nodeCard/HumanInputCard.vue +152 -0
  55. package/src/agent-flow/workflow/nodeCard/IterationCard.vue +66 -0
  56. package/src/agent-flow/workflow/nodeCard/KnowledgeRetrievalCard.vue +48 -0
  57. package/src/agent-flow/workflow/nodeCard/LLMNodeCard.vue +206 -0
  58. package/src/agent-flow/workflow/nodeCard/ListOperatorCard.vue +188 -0
  59. package/src/agent-flow/workflow/nodeCard/ParameterExtractorCard.vue +352 -0
  60. package/src/agent-flow/workflow/nodeCard/ServiceApiNodeCard.vue +188 -0
  61. package/src/agent-flow/workflow/nodeCard/StartNodeCard.vue +216 -0
  62. package/src/agent-flow/workflow/nodeCard/VariableAssignerCard.vue +125 -0
  63. package/src/agent-flow/workflow/nodeCard/VariableNodeCard.vue +121 -0
  64. package/src/agent-flow/workflow/nodes/AgentNode.vue +122 -0
  65. package/src/agent-flow/workflow/nodes/AnswerNode.vue +99 -0
  66. package/src/agent-flow/workflow/nodes/ClassifierNode.vue +199 -0
  67. package/src/agent-flow/workflow/nodes/CodeNode.vue +152 -0
  68. package/src/agent-flow/workflow/nodes/ConditionNode.vue +232 -0
  69. package/src/agent-flow/workflow/nodes/DocumentExtractorNode.vue +96 -0
  70. package/src/agent-flow/workflow/nodes/EndNode.vue +119 -0
  71. package/src/agent-flow/workflow/nodes/FileUploadNode.vue +97 -0
  72. package/src/agent-flow/workflow/nodes/HttpNode.vue +204 -0
  73. package/src/agent-flow/workflow/nodes/HumanInputNode.vue +110 -0
  74. package/src/agent-flow/workflow/nodes/IterationNode.vue +98 -0
  75. package/src/agent-flow/workflow/nodes/KnowledgeRetrievalNode.vue +253 -0
  76. package/src/agent-flow/workflow/nodes/LLMNode.vue +134 -0
  77. package/src/agent-flow/workflow/nodes/ListOperatorNode.vue +111 -0
  78. package/src/agent-flow/workflow/nodes/LoopNode.vue +94 -0
  79. package/src/agent-flow/workflow/nodes/ParameterExtractorNode.vue +147 -0
  80. package/src/agent-flow/workflow/nodes/ServiceApiNode.vue +161 -0
  81. package/src/agent-flow/workflow/nodes/StartNode.vue +199 -0
  82. package/src/agent-flow/workflow/nodes/TemplateNode.vue +103 -0
  83. package/src/agent-flow/workflow/nodes/UserInputNode.vue +92 -0
  84. package/src/agent-flow/workflow/nodes/VariableAssignerNode.vue +145 -0
  85. package/src/agent-flow/workflow/nodes/VariableNode.vue +130 -0
  86. package/src/agent-flow/workflow/nodes/_node-base.css +338 -0
  87. package/src/agent-flow/workflow/utils/node_card_form.ts +335 -0
  88. package/src/agent-flow/workflow/utils/node_config.ts +63 -0
  89. package/src/agent-flow/workflow/utils/workflow_utils.ts +151 -0
  90. package/src/agent-studio/adapters/springAgentStart.ts +390 -0
  91. package/src/agent-studio/api/index.ts +1 -0
  92. package/src/agent-studio/api/types.ts +173 -0
  93. package/src/agent-studio/components/AgentApiDocs.vue +500 -0
  94. package/src/agent-studio/components/AgentAppsPage.vue +1601 -0
  95. package/src/agent-studio/components/AgentCardGrid.vue +259 -0
  96. package/src/agent-studio/components/AgentChatPage.vue +553 -0
  97. package/src/agent-studio/components/AgentDebugPanel.vue +350 -0
  98. package/src/agent-studio/components/AgentLogsPanel.vue +391 -0
  99. package/src/agent-studio/components/AgentMonitorPanel.vue +204 -0
  100. package/src/agent-studio/components/AgentOrchestrate.vue +505 -0
  101. package/src/agent-studio/components/AgentPromptEditor.vue +237 -0
  102. package/src/agent-studio/components/AgentStudioShell.vue +227 -0
  103. package/src/agent-studio/components/AgentToolsPanel.vue +394 -0
  104. package/src/agent-studio/components/AgentVariablesPanel.vue +205 -0
  105. package/src/agent-studio/components/ApiKeyManager.vue +251 -0
  106. package/src/agent-studio/components/AppDesignDrawer.vue +3391 -0
  107. package/src/agent-studio/components/CreateAppModal.vue +737 -0
  108. package/src/agent-studio/components/SparkChart.vue +200 -0
  109. package/src/agent-studio/components/VariableEditorModal.vue +499 -0
  110. package/src/agent-studio/composables/useAgentStudio.ts +347 -0
  111. package/src/agent-studio/index.ts +60 -0
  112. package/src/agent-studio/panels/DrawerFlowDesigner.vue +203 -0
  113. package/src/agent-studio/panels/LogAnnotationPanel.vue +490 -0
  114. package/src/agent-studio/panels/MonitorPanel.vue +535 -0
  115. package/src/agent-studio/types.ts +242 -0
  116. package/src/index.ts +155 -0
  117. package/src/knowledge-hub/adapters/springAgentStart.ts +472 -0
  118. package/src/knowledge-hub/components/AddDocumentsWizard.vue +1452 -0
  119. package/src/knowledge-hub/components/CreateDatasetWizard.vue +1723 -0
  120. package/src/knowledge-hub/components/DatasetCardGrid.vue +467 -0
  121. package/src/knowledge-hub/components/DatasetDetailDrawer.vue +888 -0
  122. package/src/knowledge-hub/components/DatasetPicker.vue +65 -0
  123. package/src/knowledge-hub/components/DatasetPickerModal.vue +526 -0
  124. package/src/knowledge-hub/components/DatasetSettingsPanel.vue +908 -0
  125. package/src/knowledge-hub/components/DatasetSidebar.vue +781 -0
  126. package/src/knowledge-hub/components/DocumentChunksView.vue +802 -0
  127. package/src/knowledge-hub/components/DocumentTable.vue +552 -0
  128. package/src/knowledge-hub/components/HitTestingPanel.vue +74 -0
  129. package/src/knowledge-hub/components/KnowledgeApp.vue +66 -0
  130. package/src/knowledge-hub/components/KnowledgeHubApp.vue +504 -0
  131. package/src/knowledge-hub/components/RecallTestingPanelV2.vue +515 -0
  132. package/src/knowledge-hub/components/RetrievalConfigPopover.vue +270 -0
  133. package/src/knowledge-hub/components/RetrievalMethodPicker.vue +547 -0
  134. package/src/knowledge-hub/components/RetrievedList.vue +24 -0
  135. package/src/knowledge-hub/components/internal/KhDialog.vue +195 -0
  136. package/src/knowledge-hub/composables/useKnowledge.ts +89 -0
  137. package/src/knowledge-hub/i18n/index.ts +2 -0
  138. package/src/knowledge-hub/i18n/messages.ts +422 -0
  139. package/src/knowledge-hub/i18n/useKhI18n.ts +107 -0
  140. package/src/knowledge-hub/index.ts +46 -0
  141. package/src/knowledge-hub/styles/index.css +4 -0
  142. package/src/knowledge-hub/styles/tokens.css +138 -0
  143. package/src/knowledge-hub/types/api.ts +213 -0
  144. package/src/knowledge-hub/types/dataset.ts +88 -0
  145. package/src/knowledge-hub/types/document.ts +45 -0
  146. package/src/knowledge-hub/types/index.ts +9 -0
  147. package/src/knowledge-hub/types/retrieval.ts +46 -0
  148. package/src/provider-hub/components/CredentialForm.vue +103 -0
  149. package/src/provider-hub/components/DefaultModelsPanel.vue +270 -0
  150. package/src/provider-hub/components/GroupedModelSelect.vue +445 -0
  151. package/src/provider-hub/components/ModelCardGrid.vue +332 -0
  152. package/src/provider-hub/components/ModelParameterDrawer.vue +739 -0
  153. package/src/provider-hub/components/ModelPickerPopover.vue +1282 -0
  154. package/src/provider-hub/components/ProviderApp.vue +159 -0
  155. package/src/provider-hub/components/ProviderCredentialModal.vue +321 -0
  156. package/src/provider-hub/components/ProviderGallery.vue +131 -0
  157. package/src/provider-hub/components/ProviderHubShell.vue +1992 -0
  158. package/src/provider-hub/components/ProviderIcon.vue +130 -0
  159. package/src/provider-hub/composables/useProviderHub.ts +358 -0
  160. package/src/provider-hub/index.ts +43 -0
  161. package/src/provider-hub/svg/MiniMax.svg +1 -0
  162. package/src/provider-hub/svg/Volcengine.svg +1 -0
  163. package/src/provider-hub/svg/Zhipu.svg +1 -0
  164. package/src/provider-hub/svg/deepseek.svg +1 -0
  165. package/src/provider-hub/svg/moonshot.svg +1 -0
  166. package/src/provider-hub/svg/openai.svg +1 -0
  167. package/src/provider-hub/svg/qwen.svg +1 -0
  168. package/src/provider-hub/svg/siliconflow.svg +1 -0
  169. package/src/provider-hub/types.ts +256 -0
@@ -0,0 +1,2802 @@
1
+ <script setup lang="ts">
2
+ import { computed, defineEmits, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
3
+
4
+ import { Background } from '@vue-flow/background';
5
+ import { MarkerType, useVueFlow, VueFlow } from '@vue-flow/core';
6
+ import { MiniMap } from '@vue-flow/minimap';
7
+
8
+ import { publishWorkflow, runWorkflow, saveWorkflow } from '@/adapter/backend';
9
+ import { useWorkflowStore } from '@/stores/workflow';
10
+ import langUtils from '@/utils/langUtils';
11
+ import nodeCardForm from '@/workflow/utils/node_card_form';
12
+ import workflow_utils from '@/workflow/utils/workflow_utils';
13
+
14
+ import CustomEdge from './CustomEdge.vue';
15
+ import Icon from './Icon.vue';
16
+ import AgentNode from './nodes/AgentNode.vue';
17
+ import AnswerNode from './nodes/AnswerNode.vue';
18
+ import ClassifierNode from './nodes/ClassifierNode.vue';
19
+ import CodeNode from './nodes/CodeNode.vue';
20
+ import ConditionNode from './nodes/ConditionNode.vue';
21
+ import DocumentExtractorNode from './nodes/DocumentExtractorNode.vue';
22
+ import EndNode from './nodes/EndNode.vue';
23
+ import FileUploadNode from './nodes/FileUploadNode.vue';
24
+ import HttpNode from './nodes/HttpNode.vue';
25
+ import HumanInputNode from './nodes/HumanInputNode.vue';
26
+ import IterationNode from './nodes/IterationNode.vue';
27
+ import KnowledgeRetrievalNode from './nodes/KnowledgeRetrievalNode.vue';
28
+ import ListOperatorNode from './nodes/ListOperatorNode.vue';
29
+ import LLMNode from './nodes/LLMNode.vue';
30
+ import LoopNode from './nodes/LoopNode.vue';
31
+ import ParameterExtractorNode from './nodes/ParameterExtractorNode.vue';
32
+ import ServiceApiNode from './nodes/ServiceApiNode.vue';
33
+ import StartNode from './nodes/StartNode.vue';
34
+ import TemplateNode from './nodes/TemplateNode.vue';
35
+ import UserInputNode from './nodes/UserInputNode.vue';
36
+ import VariableAssignerNode from './nodes/VariableAssignerNode.vue';
37
+ import VariableNode from './nodes/VariableNode.vue';
38
+
39
+ import '@vue-flow/core/dist/style.css';
40
+ import '@vue-flow/core/dist/theme-default.css';
41
+ import '@vue-flow/controls/dist/style.css';
42
+ import '@vue-flow/minimap/dist/style.css';
43
+ import '@vue-flow/node-resizer/dist/style.css';
44
+ import './nodes/_node-base.css';
45
+
46
+ const props = defineProps({
47
+ mode: {
48
+ type: String,
49
+ default: () => '',
50
+ },
51
+ /** 只读模式:禁用节点拖拽、连线、删除、编辑;隐藏顶部工具栏和悬浮工具栏 */
52
+ readonly: {
53
+ type: Boolean,
54
+ default: false,
55
+ },
56
+ /**
57
+ * 所属应用 id —— 走 BackendAdapter 保存/发布时必须携带的字段。backend
58
+ * WorkflowService.save() 会把 workflows 行的 PK pin 到 appId 上,所以
59
+ * 同一 app 反复保存都命中同一行(Dify draft-per-app 语义)。
60
+ * 独立宿主(例如 /agent-flow 站点级别的 playground)应该在挂载前生成一个
61
+ * scratch UUID 传进来 —— 空值会导致宿主 adapter 抛错。
62
+ */
63
+ appId: {
64
+ type: String,
65
+ default: '',
66
+ },
67
+ /**
68
+ * 嵌入宿主模式 —— 由 AppDesignDrawer 这类宿主容器传入。宿主自己已经在
69
+ * 抽屉右上角提供了 保存 / 发布 / 关闭 按钮,这里把画布顶部工具栏里同名的
70
+ * 三颗按钮(试运行 / 保存 / 发布)隐藏,只保留撤销 / 重做 图标按钮,避免
71
+ * 重复操作和视觉噪音。独立宿主(standalone 页面)不传,工具栏保留全部按钮。
72
+ */
73
+ embedded: {
74
+ type: Boolean,
75
+ default: false,
76
+ },
77
+ });
78
+
79
+ /** 画布右键上下文菜单状态 */
80
+ const contextMenu = ref<{
81
+ visible: boolean;
82
+ x: number;
83
+ y: number;
84
+ nodeId: string | null;
85
+ } | null>({ visible: false, x: 0, y: 0, nodeId: null });
86
+
87
+ const emit = defineEmits([
88
+ 'nodeClick',
89
+ 'nodeDelete',
90
+ 'run',
91
+ 'save',
92
+ 'publish',
93
+ ]);
94
+
95
+ const {
96
+ addNodes,
97
+ removeNodes,
98
+ updateNode,
99
+ updateEdge,
100
+ addEdges,
101
+ findNode,
102
+ getNodes,
103
+ getEdges,
104
+ zoomIn,
105
+ zoomOut,
106
+ fitView,
107
+ setViewport,
108
+ getViewport,
109
+ updateNodeInternals,
110
+ } = useVueFlow();
111
+
112
+ const nodes = ref<any[]>([]);
113
+ const edges = ref<any[]>([]);
114
+ const selectedNode = ref(null);
115
+ const selectedEdge = ref(null);
116
+ const hoveredNode = ref(null);
117
+ const hoveredEdge = ref(null);
118
+ const nodeCounter = ref(0);
119
+ const showNodeSelector = ref(false);
120
+ const nodeSelectorPosition = ref({ x: 0, y: 0 });
121
+ const sourceNodeForConnection = ref(null);
122
+ const sourceHandleId = ref(null);
123
+ const hoveredNodeType = ref(null);
124
+
125
+ // 历史记录管理
126
+ const history = ref([]);
127
+ const historyIndex = ref(-1);
128
+ const maxHistorySize = 50;
129
+
130
+ const canUndo = computed(() => historyIndex.value > 0);
131
+ const canRedo = computed(() => historyIndex.value < history.value.length - 1);
132
+ const workflowStore = useWorkflowStore();
133
+
134
+ // 节点类型配置 —— 每个 `type` 字符串与 backend `NodeType` 枚举(UPPER_SNAKE)
135
+ // 保持一致,同时也是 FlowDesigner 里 `<template #node-{type}>` 注册的 slot 名。
136
+ // 设计器独有的 USER_INPUT / FILE_UPLOAD / HUMAN_INPUT / LOOP 没有严格的
137
+ // backend 对应节点,backend 的 NodeType.fromJson 会把它们映射到最接近的引擎
138
+ // 节点(前三者 → START,LOOP → ITERATION),保证保存的图仍能跑起来。
139
+ const nodeCategories = ref([
140
+ {
141
+ title: '输入',
142
+ icon: 'input',
143
+ nodes: [
144
+ {
145
+ type: 'START',
146
+ label: '开始',
147
+ icon: 'start',
148
+ description: '工作流开始节点',
149
+ },
150
+ {
151
+ type: 'USER_INPUT',
152
+ label: '用户输入',
153
+ icon: 'user',
154
+ description: '用户输入节点',
155
+ },
156
+ {
157
+ type: 'FILE_UPLOAD',
158
+ label: '文件上传',
159
+ icon: 'file',
160
+ description: '文件上传节点',
161
+ },
162
+ ],
163
+ },
164
+ {
165
+ title: 'AI 模型',
166
+ icon: 'ai',
167
+ nodes: [
168
+ { type: 'LLM', label: 'LLM', icon: 'llm', description: '大语言模型节点' },
169
+ { type: 'AGENT', label: 'Agent', icon: 'agent', description: '智能体' },
170
+ {
171
+ type: 'KNOWLEDGE_RETRIEVAL',
172
+ label: '知识检索',
173
+ icon: 'knowledge',
174
+ description: '知识库检索节点',
175
+ },
176
+ {
177
+ type: 'QUESTION_CLASSIFIER',
178
+ label: '分类器',
179
+ icon: 'classifier',
180
+ description: '文本分类节点',
181
+ },
182
+ ],
183
+ },
184
+ {
185
+ title: '逻辑',
186
+ icon: 'logic',
187
+ nodes: [
188
+ {
189
+ type: 'IF_ELSE',
190
+ label: '条件分支',
191
+ icon: 'condition',
192
+ description: '条件判断分支节点',
193
+ },
194
+ {
195
+ type: 'LOOP',
196
+ label: '循环',
197
+ icon: 'loop',
198
+ description: '循环执行节点',
199
+ },
200
+ {
201
+ type: 'ITERATION',
202
+ label: '迭代',
203
+ icon: 'loop',
204
+ description: '对列表变量执行子图',
205
+ },
206
+ {
207
+ type: 'LIST_OPERATOR',
208
+ label: '列表操作',
209
+ icon: 'variable',
210
+ description: '列表过滤/映射/排序',
211
+ },
212
+ {
213
+ type: 'VARIABLE_AGGREGATOR',
214
+ label: '变量聚合',
215
+ icon: 'variable',
216
+ description: '变量聚合节点',
217
+ },
218
+ {
219
+ type: 'PARAMETER_EXTRACTOR',
220
+ label: '参数提取',
221
+ icon: 'variable',
222
+ description: '从文本抽取结构化参数',
223
+ },
224
+ {
225
+ type: 'VARIABLE_ASSIGNER',
226
+ label: '变量赋值',
227
+ icon: 'variable',
228
+ description: '写回会话/环境变量',
229
+ },
230
+ {
231
+ type: 'HUMAN_INPUT',
232
+ label: '人工介入',
233
+ icon: 'user',
234
+ description: '暂停等待人工响应',
235
+ },
236
+ {
237
+ type: 'DOCUMENT_EXTRACTOR',
238
+ label: '文档提取',
239
+ icon: 'file',
240
+ description: '从文件解析文本',
241
+ },
242
+ ],
243
+ },
244
+ {
245
+ title: '工具',
246
+ icon: 'tools',
247
+ nodes: [
248
+ {
249
+ type: 'HTTP_REQUEST',
250
+ label: 'HTTP 请求',
251
+ icon: 'http',
252
+ description: 'HTTP 请求节点',
253
+ },
254
+ {
255
+ type: 'SERVICE_API',
256
+ label: '服务接口',
257
+ icon: 'service',
258
+ description: '调用内部服务接口(免鉴权)',
259
+ },
260
+ {
261
+ type: 'CODE',
262
+ label: '代码执行',
263
+ icon: 'code',
264
+ description: '代码执行节点',
265
+ },
266
+ {
267
+ type: 'TEMPLATE_TRANSFORM',
268
+ label: '模板转换',
269
+ icon: 'template',
270
+ description: '模板转换节点',
271
+ },
272
+ ],
273
+ },
274
+ {
275
+ title: '输出',
276
+ icon: 'output',
277
+ nodes: [
278
+ {
279
+ type: 'ANSWER',
280
+ label: '直接回复',
281
+ icon: 'answer',
282
+ description: '直接回复节点',
283
+ },
284
+ {
285
+ type: 'END',
286
+ label: '结束',
287
+ icon: 'end',
288
+ description: '工作流结束节点',
289
+ },
290
+ ],
291
+ },
292
+ ]);
293
+
294
+ const initialNodes = [
295
+ {
296
+ id: '1',
297
+ type: 'START',
298
+ position: { x: 100, y: 100 },
299
+ data: {
300
+ label: '开始',
301
+ description: '工作流开始节点',
302
+ mode: '',
303
+ },
304
+ },
305
+ {
306
+ id: '2',
307
+ type: 'LLM',
308
+ position: { x: 500, y: 100 },
309
+ data: {
310
+ label: 'LLM',
311
+ description: '大语言模型节点',
312
+ config: {
313
+ model: 'gpt-3.5-turbo',
314
+ temperature: 0.7,
315
+ max_tokens: 1000,
316
+ },
317
+ },
318
+ },
319
+ {
320
+ id: '3',
321
+ type: 'ANSWER',
322
+ position: { x: 900, y: 100 },
323
+ data: {
324
+ label: '直接回复',
325
+ description: '直接回复用户',
326
+ config: {},
327
+ },
328
+ },
329
+ ];
330
+
331
+ const initialEdges = [
332
+ {
333
+ id: 'e1-2',
334
+ source: '1',
335
+ target: '2',
336
+ animated: true,
337
+ style: { stroke: '#94a3b8', strokeWidth: 2 },
338
+ type: 'custom',
339
+ markerEnd: MarkerType.ArrowClosed,
340
+ },
341
+ {
342
+ id: 'e2-3',
343
+ source: '2',
344
+ target: '3',
345
+ animated: true,
346
+ style: { stroke: '#94a3b8', strokeWidth: 2 },
347
+ type: 'custom',
348
+ markerEnd: MarkerType.ArrowClosed,
349
+ },
350
+ ];
351
+ const defaultViewport: any = { x: 0, y: 0, zoom: 0.8 };
352
+
353
+ function initGraph() {
354
+ setTimeout(() => {
355
+ let edgesList = [];
356
+ let nodeList = [];
357
+ if (props.mode === 'WORKFLOW') {
358
+ // 新建工作流默认给出 开始 -> LLM -> 结束 三节点,避免画布空白
359
+ const start = langUtils.clone(initialNodes[0]);
360
+ start.data.mode = props.mode;
361
+ const llm = langUtils.clone(initialNodes[1]);
362
+ const end = {
363
+ id: '3',
364
+ type: 'END',
365
+ position: { x: 900, y: 100 },
366
+ data: {
367
+ label: '结束',
368
+ description: '工作流结束节点',
369
+ },
370
+ };
371
+ nodeList = [start, llm, end];
372
+ edgesList = [
373
+ {
374
+ id: 'e1-2',
375
+ source: '1',
376
+ target: '2',
377
+ animated: true,
378
+ style: { stroke: '#94a3b8', strokeWidth: 2 },
379
+ type: 'custom',
380
+ markerEnd: MarkerType.ArrowClosed,
381
+ },
382
+ {
383
+ id: 'e2-3',
384
+ source: '2',
385
+ target: '3',
386
+ animated: true,
387
+ style: { stroke: '#94a3b8', strokeWidth: 2 },
388
+ type: 'custom',
389
+ markerEnd: MarkerType.ArrowClosed,
390
+ },
391
+ ];
392
+ } else if (props.mode === 'CHATFLOW') {
393
+ // Chatflow 默认给出 开始 -> LLM -> 直接回复 三节点。ANSWER 节点的语义
394
+ // 是"把上游文本作为最终回复输出",backend AnswerNodeExecutor 输出
395
+ // {answer: ...},WorkflowChatGenerator.extractAnswer() 读到后按 SSE
396
+ // message 事件推给前端。
397
+ const start = langUtils.clone(initialNodes[0]);
398
+ start.data.mode = props.mode;
399
+ const llm = langUtils.clone(initialNodes[1]);
400
+ const answer = {
401
+ id: '3',
402
+ type: 'ANSWER',
403
+ position: { x: 900, y: 100 },
404
+ data: {
405
+ label: '直接回复',
406
+ description: '把 LLM 结果作为最终回复返回',
407
+ // Seed 一个默认模板,引用 LLM 节点的输出。用户可以在节点配置里
408
+ // 覆写为任意 {{#节点id.字段#}} 组合的话术。
409
+ answer: '{{#2.text#}}',
410
+ config: {},
411
+ },
412
+ };
413
+ nodeList = [start, llm, answer];
414
+ edgesList = [
415
+ {
416
+ id: 'e1-2',
417
+ source: '1',
418
+ target: '2',
419
+ animated: true,
420
+ style: { stroke: '#94a3b8', strokeWidth: 2 },
421
+ type: 'custom',
422
+ markerEnd: MarkerType.ArrowClosed,
423
+ },
424
+ {
425
+ id: 'e2-3',
426
+ source: '2',
427
+ target: '3',
428
+ animated: true,
429
+ style: { stroke: '#94a3b8', strokeWidth: 2 },
430
+ type: 'custom',
431
+ markerEnd: MarkerType.ArrowClosed,
432
+ },
433
+ ];
434
+ } else {
435
+ nodeList = langUtils.clone(initialNodes);
436
+ edgesList = langUtils.clone(initialEdges);
437
+ }
438
+ workflow_utils.initNodeDefaultData(nodeList);
439
+ nodes.value = nodeList;
440
+ edges.value = edgesList;
441
+ nodeCounter.value = nodeList.length;
442
+ setViewport(defaultViewport);
443
+ const graph = { nodes: nodeList, edges: edgesList };
444
+ workflowStore.setGraph(graph);
445
+ // When FlowDesigner mounts inside a drawer / modal, vue-flow's
446
+ // fit-view-on-init sees the initial (empty) nodes list and never re-fits
447
+ // once we push the 3 defaults. Force a fitView so the canonical seed is
448
+ // actually visible instead of parked at (0,0) with zoom 0.8.
449
+ setTimeout(() => {
450
+ try {
451
+ fitView({ padding: 0.2 });
452
+ } catch {
453
+ // vue-flow may not be fully mounted — no-op is fine.
454
+ }
455
+ }, 30);
456
+ }, 10);
457
+ }
458
+
459
+ function resetGraph() {
460
+ const graph = { nodes: nodes.value, edges: edges.value };
461
+ workflowStore.setGraph(graph);
462
+ }
463
+
464
+ function reloadGraph(graph: any) {
465
+ if (!graph) {
466
+ initGraph();
467
+ return;
468
+ }
469
+ // `[]` is truthy in JS — a saved-but-empty graph must fall through to
470
+ // initGraph() too, otherwise the canvas would render permanently blank on
471
+ // reopen.
472
+ if (Array.isArray(graph.nodes) && graph.nodes.length > 0) {
473
+ // Merge in default per-type form data so persisted (creation-seed or
474
+ // externally-produced) graphs get the same node.data shape that
475
+ // `initGraph()` sets up. Without this, LLMNode / KnowledgeRetrievalNode /
476
+ // etc. may render with missing config fields.
477
+ workflow_utils.initNodeDefaultData(graph.nodes);
478
+ nodes.value = graph.nodes;
479
+ edges.value = graph.edges ?? [];
480
+ nodeCounter.value = graph.nodes.length;
481
+ // Persisted graphs from before viewport was tracked won't carry one —
482
+ // fall back to a full {x,y,zoom} shape so vue-flow's setViewport doesn't
483
+ // dereference `undefined.x`.
484
+ setViewport(graph.viewport ?? { x: 0, y: 0, zoom: 0.8 });
485
+ workflowStore.setGraph(graph);
486
+ // vue-flow's fit-view-on-init only runs on the very first mount; a
487
+ // subsequent reloadGraph() needs an explicit fitView so the newly-loaded
488
+ // nodes land in view.
489
+ setTimeout(() => {
490
+ try {
491
+ fitView({ padding: 0.2 });
492
+ } catch {
493
+ // vue-flow may not be fully mounted yet — no-op is fine.
494
+ }
495
+ }, 30);
496
+ } else {
497
+ initGraph();
498
+ }
499
+ }
500
+
501
+ /** 在画布空白处添加节点 —— 从 API 层被调用,非主路径 */
502
+ function addNode(type) {
503
+ const newNodeId = langUtils.getId('node');
504
+ const newNode = {
505
+ id: newNodeId,
506
+ type,
507
+ position: findFreePosition(),
508
+ data: {
509
+ label: getDefaultLabel(type),
510
+ description: getDefaultDescription(type),
511
+ ...getDefaultConfig(type),
512
+ },
513
+ };
514
+ addNodes([newNode]);
515
+ saveToHistory();
516
+ resetGraph();
517
+ }
518
+
519
+ /** 找一个不与已有节点重叠的空白位置 */
520
+ function findFreePosition(): { x: number; y: number } {
521
+ const NODE_W = 300;
522
+ const NODE_H = 180;
523
+ const START_X = 200;
524
+ const START_Y = 200;
525
+ const STEP_X = 350;
526
+ const STEP_Y = 220;
527
+
528
+ const occupied = (x: number, y: number) =>
529
+ nodes.value.some((n) => {
530
+ const nx = n.position?.x ?? 0;
531
+ const ny = n.position?.y ?? 0;
532
+ return (
533
+ Math.abs(nx - x) < NODE_W * 0.6 &&
534
+ Math.abs(ny - y) < NODE_H * 0.6
535
+ );
536
+ });
537
+
538
+ // 按网格扫,行优先
539
+ for (let row = 0; row < 20; row++) {
540
+ for (let col = 0; col < 20; col++) {
541
+ const x = START_X + col * STEP_X;
542
+ const y = START_Y + row * STEP_Y;
543
+ if (!occupied(x, y)) return { x, y };
544
+ }
545
+ }
546
+ return { x: START_X, y: START_Y };
547
+ }
548
+
549
+ function getDefaultLabel(type) {
550
+ const labels = {
551
+ START: '开始',
552
+ USER_INPUT: '用户输入',
553
+ FILE_UPLOAD: '文件上传',
554
+ LLM: 'LLM',
555
+ AGENT: 'Agent',
556
+ KNOWLEDGE_RETRIEVAL: '知识检索',
557
+ QUESTION_CLASSIFIER: '问题分类',
558
+ IF_ELSE: '条件分支',
559
+ LOOP: '循环',
560
+ ITERATION: '迭代',
561
+ LIST_OPERATOR: '列表操作',
562
+ VARIABLE_AGGREGATOR: '变量聚合',
563
+ VARIABLE_ASSIGNER: '变量赋值',
564
+ PARAMETER_EXTRACTOR: '参数提取',
565
+ DOCUMENT_EXTRACTOR: '文档提取',
566
+ HUMAN_INPUT: '人工介入',
567
+ HTTP_REQUEST: 'HTTP 请求',
568
+ SERVICE_API: '服务接口',
569
+ CODE: '代码执行',
570
+ TEMPLATE_TRANSFORM: '模板转换',
571
+ ANSWER: '直接回复',
572
+ END: '结束',
573
+ };
574
+ return labels[type] || '节点';
575
+ }
576
+
577
+ function getDefaultDescription(type) {
578
+ const descriptions = {
579
+ START: '工作流开始节点',
580
+ USER_INPUT: '用户输入节点',
581
+ FILE_UPLOAD: '文件上传节点',
582
+ LLM: '大语言模型节点',
583
+ AGENT: 'Agent 智能体',
584
+ KNOWLEDGE_RETRIEVAL: '知识库检索节点',
585
+ QUESTION_CLASSIFIER: '文本分类节点',
586
+ IF_ELSE: '条件判断分支节点',
587
+ LOOP: '循环执行节点',
588
+ ITERATION: '对列表变量的每一项执行子图',
589
+ LIST_OPERATOR: '对列表变量做过滤/映射/排序等操作',
590
+ VARIABLE_AGGREGATOR: '变量聚合节点',
591
+ VARIABLE_ASSIGNER: '把上游变量写回到会话/环境变量',
592
+ PARAMETER_EXTRACTOR: '从上游文本中抽取结构化参数',
593
+ DOCUMENT_EXTRACTOR: '从文件变量中提取文本内容',
594
+ HUMAN_INPUT: '暂停工作流等待人工审批或补充信息',
595
+ HTTP_REQUEST: 'HTTP 请求节点',
596
+ SERVICE_API: '调用内部服务接口(免鉴权)',
597
+ CODE: '代码执行节点',
598
+ TEMPLATE_TRANSFORM: '模板转换节点',
599
+ ANSWER: '直接回复节点',
600
+ END: '工作流结束节点',
601
+ };
602
+ return descriptions[type] || '节点描述';
603
+ }
604
+
605
+ function getDefaultConfig(type) {
606
+ const configs = nodeCardForm;
607
+ return langUtils.clone(configs[type]) || {};
608
+ }
609
+
610
+ function getNodeTypeLabel(type) {
611
+ return getDefaultLabel(type);
612
+ }
613
+
614
+ function onNodesChange(changes: any[]) {
615
+ for (const change of changes) {
616
+ if (change.type === 'remove') {
617
+ emit('nodeDelete', change.id);
618
+ }
619
+ }
620
+ }
621
+
622
+ function onEdgesChange(_changes) {
623
+ // 由 vue-flow 内部处理,我们的自定义副作用(如保存历史)在 add/remove 处显式触发
624
+ }
625
+
626
+ function onConnect(params) {
627
+ const newEdge = {
628
+ id: `e${params.source}-${params.target}-${Date.now()}`,
629
+ source: params.source,
630
+ target: params.target,
631
+ sourceHandle: params.sourceHandle,
632
+ targetHandle: params.targetHandle,
633
+ animated: true,
634
+ style: {
635
+ stroke: '#94a3b8',
636
+ strokeWidth: 2,
637
+ },
638
+ type: 'custom',
639
+ markerEnd: MarkerType.ArrowClosed,
640
+ };
641
+ addEdges([newEdge]);
642
+ saveToHistory();
643
+ resetGraph();
644
+ }
645
+
646
+ function onNodeClick(event) {
647
+ selectedNode.value = event.node;
648
+ // 不更新节点的选中状态,避免置灰效果
649
+ emit('nodeClick', event.node);
650
+ }
651
+
652
+ function onNodeMouseEnter(event) {
653
+ hoveredNode.value = event.node;
654
+ // 高亮相关连接线
655
+ highlightConnectedEdges(event.node.id, true);
656
+ }
657
+
658
+ function onNodeMouseLeave(event) {
659
+ hoveredNode.value = null;
660
+ // 取消高亮连接线
661
+ highlightConnectedEdges(event.node.id, false);
662
+ }
663
+
664
+ function highlightConnectedEdges(nodeId, highlight) {
665
+ edges.value = edges.value.map((edge) => {
666
+ const isConnected = edge.source === nodeId || edge.target === nodeId;
667
+ const isSelected = selectedEdge.value && selectedEdge.value.id === edge.id;
668
+
669
+ return {
670
+ ...edge,
671
+ style: {
672
+ ...edge.style,
673
+ stroke: isSelected
674
+ ? '#1d4ed8'
675
+ : highlight && isConnected
676
+ ? '#3b82f6'
677
+ : '#6366f1',
678
+ strokeWidth: isSelected ? 4 : highlight && isConnected ? 3 : 2,
679
+ },
680
+ animated: !isSelected, // Disable animation for selected edges
681
+ };
682
+ });
683
+ }
684
+
685
+ function onEdgeClick(event) {
686
+ // Toggle selected edge
687
+ if (selectedEdge.value && selectedEdge.value.id === event.edge.id) {
688
+ // Deselect if clicking the same edge
689
+ selectedEdge.value = null;
690
+ } else {
691
+ // Select new edge
692
+ selectedEdge.value = event.edge;
693
+ }
694
+
695
+ // Update edges styling based on selection
696
+ updateEdgeStyles();
697
+ }
698
+
699
+ function updateEdgeStyles() {
700
+ edges.value = edges.value.map((edge) => {
701
+ const isSelected = selectedEdge.value && selectedEdge.value.id === edge.id;
702
+
703
+ return {
704
+ ...edge,
705
+ style: {
706
+ ...edge.style,
707
+ stroke: isSelected ? '#1d4ed8' : '#6366f1',
708
+ strokeWidth: isSelected ? 4 : 2,
709
+ },
710
+ animated: !isSelected, // Disable animation for selected edges to make them solid
711
+ };
712
+ });
713
+ }
714
+
715
+ function onPaneClick() {
716
+ selectedNode.value = null;
717
+ selectedEdge.value = null;
718
+ // 不需要更新节点状态,避免置灰效果
719
+ updateEdgeStyles();
720
+ }
721
+
722
+ function updateNodeData(nodeData) {
723
+ if (selectedNode.value) {
724
+ updateNode(selectedNode.value.id, nodeData);
725
+ selectedNode.value = { ...selectedNode.value, ...nodeData };
726
+ }
727
+ }
728
+
729
+ function deleteNode() {
730
+ if (selectedNode.value) {
731
+ deleteNodeById(selectedNode.value.id);
732
+ }
733
+ }
734
+
735
+ /** 按 id 删除节点:同时清理相关的边,start 节点保护 */
736
+ function deleteNodeById(nodeId) {
737
+ if (!nodeId) return;
738
+ const node = findNode(nodeId);
739
+ if (!node) return;
740
+ if (node.type === 'START') {
741
+ langUtils.message({ code: 1, message: '开始节点不可删除' });
742
+ return;
743
+ }
744
+ // 先删所有连着的边
745
+ const relatedEdges = edges.value.filter(
746
+ (e) => e.source === nodeId || e.target === nodeId,
747
+ );
748
+ if (relatedEdges.length > 0) {
749
+ edges.value = edges.value.filter(
750
+ (e) => e.source !== nodeId && e.target !== nodeId,
751
+ );
752
+ }
753
+ removeNodes([nodeId]);
754
+ if (selectedNode.value?.id === nodeId) {
755
+ selectedNode.value = null;
756
+ emit('nodeDelete', nodeId);
757
+ }
758
+ saveToHistory();
759
+ resetGraph();
760
+ }
761
+
762
+ /** 按 id 复制节点:偏移 40px 生成新 id 的克隆 */
763
+ function duplicateNodeById(nodeId) {
764
+ const original = findNode(nodeId);
765
+ if (!original) return;
766
+ if (original.type === 'START') {
767
+ langUtils.message({ code: 1, message: '开始节点不可复制' });
768
+ return;
769
+ }
770
+ const newId = langUtils.getId(original.type);
771
+ const cloned = langUtils.clone(original);
772
+ cloned.id = newId;
773
+ cloned.data = { ...cloned.data, id: newId };
774
+ cloned.position = {
775
+ x: (original.position?.x || 0) + 40,
776
+ y: (original.position?.y || 0) + 40,
777
+ };
778
+ cloned.selected = false;
779
+ addNodes([cloned]);
780
+ saveToHistory();
781
+ resetGraph();
782
+ }
783
+
784
+ const running = ref(false);
785
+ const saving = ref(false);
786
+ const publishing = ref(false);
787
+
788
+ /** 校验工作流图;返回错误信息数组,为空数组表示通过 */
789
+ function validateWorkflow(): string[] {
790
+ const errors: string[] = [];
791
+ const ns = nodes.value;
792
+ const es = edges.value;
793
+ const starts = ns.filter((n) => n.type === 'START');
794
+ const ends = ns.filter((n) => n.type === 'END' || n.type === 'ANSWER');
795
+
796
+ if (starts.length === 0) errors.push('缺少开始节点');
797
+ if (starts.length > 1) errors.push('存在多个开始节点');
798
+ if (ends.length === 0) errors.push('缺少结束/回复节点');
799
+
800
+ // 计算 start 可达的节点集合
801
+ const adj: Record<string, string[]> = {};
802
+ for (const e of es) {
803
+ (adj[e.source] ||= []).push(e.target);
804
+ }
805
+ const reachable = new Set<string>();
806
+ const queue: string[] = starts.map((s) => s.id);
807
+ while (queue.length > 0) {
808
+ const cur = queue.shift()!;
809
+ if (reachable.has(cur)) continue;
810
+ reachable.add(cur);
811
+ for (const next of adj[cur] || []) queue.push(next);
812
+ }
813
+
814
+ const unreachable = ns.filter((n) => !reachable.has(n.id));
815
+ if (unreachable.length > 0) {
816
+ errors.push(
817
+ `${unreachable.length} 个节点未与开始节点连通:${unreachable
818
+ .map((n) => n.data?.label || n.id)
819
+ .slice(0, 3)
820
+ .join('、')}${unreachable.length > 3 ? ' 等' : ''}`,
821
+ );
822
+ }
823
+
824
+ // end 必须能被 start 触达
825
+ const endReachable = ends.some((e) => reachable.has(e.id));
826
+ if (ends.length > 0 && !endReachable) {
827
+ errors.push('结束节点无法从开始节点触达');
828
+ }
829
+
830
+ return errors;
831
+ }
832
+
833
+ function getFlowInfo() {
834
+ return {
835
+ nodes: nodes.value,
836
+ edges: edges.value,
837
+ viewport: getViewport(),
838
+ };
839
+ }
840
+
841
+ /** 顶部工具栏「试运行」:走 BackendAdapter.runWorkflow */
842
+ async function onRun() {
843
+ if (running.value) return;
844
+ running.value = true;
845
+ try {
846
+ const graph = getFlowInfo();
847
+ const res: any = await runWorkflow({ graph });
848
+ langUtils.message(res?.data ?? { code: 0, message: '已提交试运行' });
849
+ emit('run', { graph, response: res });
850
+ } catch (err: any) {
851
+ langUtils.message({ code: 1, message: err?.message || '试运行失败' });
852
+ } finally {
853
+ running.value = false;
854
+ }
855
+ }
856
+
857
+ /**
858
+ * 顶部工具栏「保存草稿」:走 BackendAdapter.saveWorkflow —— 必须携带 appId,
859
+ * backend 会把 workflows 行 PK pin 到 appId,保证同一 app 反复保存都命中同一行。
860
+ * 宿主组件(例如 AppDesignDrawer)负责把 :app-id 传下来;本组件不再自作聪明
861
+ * 生成 UUID —— appId 缺失就直接向上抛,交由宿主处理。
862
+ */
863
+ async function onSaveDraft() {
864
+ if (saving.value) return;
865
+ if (!props.appId) {
866
+ langUtils.message({
867
+ code: 1,
868
+ message: '缺少 appId:请在宿主上传入 :app-id',
869
+ });
870
+ return;
871
+ }
872
+ saving.value = true;
873
+ try {
874
+ const graph = getFlowInfo();
875
+ const res: any = await saveWorkflow({ appId: props.appId, graph });
876
+ langUtils.message(res?.data ?? { code: 0, message: '已保存' });
877
+ emit('save', { graph, response: res });
878
+ } catch (err: any) {
879
+ langUtils.message({ code: 1, message: err?.message || '保存失败' });
880
+ } finally {
881
+ saving.value = false;
882
+ }
883
+ }
884
+
885
+ /** 顶部工具栏「发布」:走 BackendAdapter.publishWorkflow */
886
+ async function onPublish() {
887
+ if (publishing.value) return;
888
+ if (!props.appId) {
889
+ langUtils.message({
890
+ code: 1,
891
+ message: '缺少 appId:请在宿主上传入 :app-id',
892
+ });
893
+ return;
894
+ }
895
+ const errors = validateWorkflow();
896
+ if (errors.length > 0) {
897
+ langUtils.message({
898
+ code: 1,
899
+ message: `无法发布:${errors.join(';')}`,
900
+ });
901
+ return;
902
+ }
903
+ publishing.value = true;
904
+ try {
905
+ const graph = getFlowInfo();
906
+ const res: any = await publishWorkflow({ appId: props.appId, graph });
907
+ langUtils.message(res?.data ?? { code: 0, message: '已发布' });
908
+ emit('publish', { graph, response: res });
909
+ } catch (err: any) {
910
+ langUtils.message({ code: 1, message: err?.message || '发布失败' });
911
+ } finally {
912
+ publishing.value = false;
913
+ }
914
+ }
915
+
916
+ function onConnectionPlusClick(event, sourceNodeId, handleId = null) {
917
+ event.stopPropagation();
918
+
919
+ // 直接获取点击位置的坐标
920
+ const rect = event.target.getBoundingClientRect();
921
+ const canvasRect = document
922
+ .querySelector('.center-canvas')
923
+ .getBoundingClientRect();
924
+
925
+ // 面板大小估算
926
+ const panelWidth = 280;
927
+ const panelHeight = 400;
928
+
929
+ // 使用点击位置的x坐标作为面板左侧位置
930
+ let x = rect.left - canvasRect.left + rect.width / 2;
931
+
932
+ // 使用点击位置的y坐标作为面板垂直中心位置
933
+ let y = rect.top - canvasRect.top + rect.height / 2 - panelHeight / 2;
934
+
935
+ // 确保面板不超出右边界
936
+ if (x + panelWidth > canvasRect.width - 20) {
937
+ x = canvasRect.width - panelWidth - 20;
938
+ }
939
+
940
+ // 确保面板不超出左边界
941
+ if (x < 20) {
942
+ x = 20;
943
+ }
944
+
945
+ // 确保面板不超出下边界
946
+ if (y + panelHeight > canvasRect.height - 20) {
947
+ y = canvasRect.height - panelHeight - 20;
948
+ }
949
+
950
+ // 确保面板不超出上边界
951
+ if (y < 20) {
952
+ y = 20;
953
+ }
954
+
955
+ nodeSelectorPosition.value = { x, y };
956
+ sourceNodeForConnection.value = sourceNodeId;
957
+ sourceHandleId.value = handleId; // 保存源连接点ID
958
+ showNodeSelector.value = true;
959
+ }
960
+
961
+ function onNodeSelectorSelect(nodeType) {
962
+ if (sourceNodeForConnection.value) {
963
+ // Normal node creation (existing logic)
964
+ const sourceNode = nodes.value.find(
965
+ (n) => n.id === sourceNodeForConnection.value,
966
+ );
967
+ if (sourceNode) {
968
+ const newNodeId = langUtils.getId(nodeType);
969
+
970
+ // 从 sourceNode 右侧开始扫描,找到第一个不与已有节点重叠的位置
971
+ const preferred = {
972
+ x: (sourceNode.position?.x ?? 0) + 350,
973
+ y: sourceNode.position?.y ?? 0,
974
+ };
975
+ const isOccupied = (x: number, y: number) =>
976
+ nodes.value.some((n) => {
977
+ if (n.id === sourceNode.id) return false;
978
+ const nx = n.position?.x ?? 0;
979
+ const ny = n.position?.y ?? 0;
980
+ return Math.abs(nx - x) < 200 && Math.abs(ny - y) < 120;
981
+ });
982
+ let position = { ...preferred };
983
+ if (isOccupied(position.x, position.y)) {
984
+ // 上下扫
985
+ for (let offset = 220; offset < 2000; offset += 220) {
986
+ if (!isOccupied(preferred.x, preferred.y - offset)) {
987
+ position = { x: preferred.x, y: preferred.y - offset };
988
+ break;
989
+ }
990
+ if (!isOccupied(preferred.x, preferred.y + offset)) {
991
+ position = { x: preferred.x, y: preferred.y + offset };
992
+ break;
993
+ }
994
+ }
995
+ }
996
+
997
+ const newNode = {
998
+ id: newNodeId,
999
+ type: nodeType,
1000
+ position,
1001
+ data: {
1002
+ id: newNodeId,
1003
+ label: getDefaultLabel(nodeType),
1004
+ description: getDefaultDescription(nodeType),
1005
+ ...getDefaultConfig(nodeType),
1006
+ },
1007
+ };
1008
+
1009
+ // 添加节点
1010
+ addNodes([newNode]);
1011
+
1012
+ // 创建连接
1013
+ // 关键点:
1014
+ // 1. edge.id 里带上 sourceHandle,避免"同一个 source node、不同 case 分支"
1015
+ // 的多条边生成同一个 id(旧的 `e{src}-{tgt}` 会撞 id,VueFlow 后加入的边
1016
+ // 会静默复用先前那条的 sourceHandle,视觉上就变成"从上一个 case 处出线")。
1017
+ // 2. 明确写死 targetHandle: null(不是 undefined),让 VueFlow 用目标节点默认
1018
+ // 的 target handle,而不是漂到未命名/最近的一个 handle 上。
1019
+ const sourceHandleForEdge = sourceHandleId.value ?? null;
1020
+ const newEdge = {
1021
+ id: `e${sourceNodeForConnection.value}${sourceHandleForEdge ? `.${sourceHandleForEdge}` : ''}-${newNodeId}`,
1022
+ source: sourceNodeForConnection.value,
1023
+ target: newNodeId,
1024
+ sourceHandle: sourceHandleForEdge,
1025
+ targetHandle: null,
1026
+ animated: true,
1027
+ style: { stroke: '#94a3b8', strokeWidth: 2 },
1028
+ type: 'custom',
1029
+ markerEnd: MarkerType.ArrowClosed,
1030
+ };
1031
+
1032
+ // addNodes 是同步入 store,但新节点的 handle DOM 尚未挂载 —— 此时 addEdges
1033
+ // 会用 VueFlow 里"没这个 handle"的 fallback 位置绘制边(对 condition 这种
1034
+ // 多 handle 节点,fallback 常常落在节点顶部/最靠近上级节点的锚点,看起来就
1035
+ // 像新边被拉回上级去了)。等一个 tick 让新节点 DOM 挂载,然后 addEdges,
1036
+ // 最后再显式 updateNodeInternals 让 source(可能是多 handle 的条件节点)与
1037
+ // target 都重新测一遍 bounding rect。
1038
+ const sourceIdForEdge = sourceNodeForConnection.value;
1039
+ nextTick(() => {
1040
+ addEdges([newEdge]);
1041
+ updateNodeInternals([sourceIdForEdge, newNodeId]);
1042
+ });
1043
+ saveToHistory();
1044
+ }
1045
+ }
1046
+
1047
+ // 关闭选择器
1048
+ closeNodeSelector();
1049
+
1050
+ setTimeout(() => {
1051
+ resetGraph();
1052
+ }, 200);
1053
+ }
1054
+
1055
+ function closeNodeSelector() {
1056
+ showNodeSelector.value = false;
1057
+ sourceNodeForConnection.value = null;
1058
+ sourceHandleId.value = null;
1059
+ hoveredNodeType.value = null;
1060
+ nodeSearchQuery.value = '';
1061
+ }
1062
+
1063
+ function onNodeItemMouseEnter(event, nodeData) {
1064
+ hoveredNodeType.value = {
1065
+ ...nodeData,
1066
+ position: {
1067
+ x: event.currentTarget.offsetLeft,
1068
+ y: event.currentTarget.offsetTop,
1069
+ },
1070
+ };
1071
+ }
1072
+
1073
+ function onNodeItemMouseLeave() {
1074
+ hoveredNodeType.value = null;
1075
+ }
1076
+
1077
+ /** ------ 节点选择面板:搜索 + 拖动 ------ */
1078
+ const nodeSearchQuery = ref('');
1079
+
1080
+ const filteredCategories = computed(() => {
1081
+ const q = nodeSearchQuery.value.trim().toLowerCase();
1082
+ if (!q) return nodeCategories.value;
1083
+ return nodeCategories.value
1084
+ .map((cat) => ({
1085
+ ...cat,
1086
+ nodes: cat.nodes.filter(
1087
+ (n: any) =>
1088
+ n.label.toLowerCase().includes(q) ||
1089
+ (n.description || '').toLowerCase().includes(q) ||
1090
+ n.type.toLowerCase().includes(q),
1091
+ ),
1092
+ }))
1093
+ .filter((cat) => cat.nodes.length > 0);
1094
+ });
1095
+
1096
+ let dragState: {
1097
+ startX: number;
1098
+ startY: number;
1099
+ origX: number;
1100
+ origY: number;
1101
+ } | null = null;
1102
+
1103
+ function onSelectorHeaderMouseDown(e: MouseEvent) {
1104
+ dragState = {
1105
+ startX: e.clientX,
1106
+ startY: e.clientY,
1107
+ origX: nodeSelectorPosition.value.x,
1108
+ origY: nodeSelectorPosition.value.y,
1109
+ };
1110
+ window.addEventListener('mousemove', onSelectorDragMove);
1111
+ window.addEventListener('mouseup', onSelectorDragEnd);
1112
+ e.preventDefault();
1113
+ }
1114
+
1115
+ function onSelectorDragMove(e: MouseEvent) {
1116
+ if (!dragState) return;
1117
+ const dx = e.clientX - dragState.startX;
1118
+ const dy = e.clientY - dragState.startY;
1119
+ nodeSelectorPosition.value = {
1120
+ x: dragState.origX + dx,
1121
+ y: dragState.origY + dy,
1122
+ };
1123
+ }
1124
+
1125
+ function onSelectorDragEnd() {
1126
+ dragState = null;
1127
+ window.removeEventListener('mousemove', onSelectorDragMove);
1128
+ window.removeEventListener('mouseup', onSelectorDragEnd);
1129
+ }
1130
+
1131
+ function getNodeColor(node) {
1132
+ const colors = {
1133
+ start: '#10b981',
1134
+ llm: '#6366f1',
1135
+ knowledge_retrieval: '#8b5cf6',
1136
+ condition: '#f59e0b',
1137
+ http: '#06b6d4',
1138
+ code: '#84cc16',
1139
+ answer: '#f97316',
1140
+ end: '#ef4444',
1141
+ };
1142
+ return colors[node.type] || '#6b7280';
1143
+ }
1144
+
1145
+ // 历史记录功能
1146
+ function saveToHistory() {
1147
+ const currentState = {
1148
+ nodes: JSON.parse(JSON.stringify(nodes.value)),
1149
+ edges: JSON.parse(JSON.stringify(edges.value)),
1150
+ };
1151
+
1152
+ // 如果当前不在历史记录末尾,删除后续记录
1153
+ if (historyIndex.value < history.value.length - 1) {
1154
+ history.value = history.value.slice(0, historyIndex.value + 1);
1155
+ }
1156
+
1157
+ history.value.push(currentState);
1158
+
1159
+ // 限制历史记录大小
1160
+ if (history.value.length > maxHistorySize) {
1161
+ history.value.shift();
1162
+ } else {
1163
+ historyIndex.value++;
1164
+ }
1165
+ }
1166
+
1167
+ function undo() {
1168
+ if (canUndo.value) {
1169
+ historyIndex.value--;
1170
+ const state = history.value[historyIndex.value];
1171
+ nodes.value = JSON.parse(JSON.stringify(state.nodes));
1172
+ edges.value = JSON.parse(JSON.stringify(state.edges));
1173
+ }
1174
+ }
1175
+
1176
+ function redo() {
1177
+ if (canRedo.value) {
1178
+ historyIndex.value++;
1179
+ const state = history.value[historyIndex.value];
1180
+ nodes.value = JSON.parse(JSON.stringify(state.nodes));
1181
+ edges.value = JSON.parse(JSON.stringify(state.edges));
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * 节点自动排列(分层 Sugiyama 风格)
1187
+ * 步骤:
1188
+ * 1. 分层:按最长路径把节点分到不同 rank(rank 越大越靠右);
1189
+ * 强制 start 落到 rank 0、end 落到最右 rank。
1190
+ * 2. 减少交叉:多轮 barycenter(正向按父节点平均序、反向按子节点平均序)。
1191
+ * 3. 定位:每层 X 由前一层最大宽度 + 水平 gap 决定;
1192
+ * 每层内节点先按 order 排开,再取父节点平均 Y 作为初始 Y,
1193
+ * 最后按顺序做重叠推挤(下一节点若与前一节点重叠则往下推)。
1194
+ * 目标:连线尽量水平、不交叉;同层节点严格不重叠。
1195
+ */
1196
+ function arrangeNodes() {
1197
+ const list = nodes.value;
1198
+ if (!list || list.length === 0) return;
1199
+
1200
+ const H_GAP = 80; // 层与层之间水平 gap
1201
+ const V_GAP = 40; // 同层节点之间最小垂直 gap
1202
+ const START_X = 100;
1203
+ const BASELINE_Y = 200;
1204
+ const DEFAULT_W = 300;
1205
+ const DEFAULT_H = 140;
1206
+
1207
+ const nodeMap = new Map();
1208
+ const outgoing = new Map();
1209
+ const incoming = new Map();
1210
+ list.forEach((n) => {
1211
+ nodeMap.set(n.id, n);
1212
+ outgoing.set(n.id, []);
1213
+ incoming.set(n.id, []);
1214
+ });
1215
+ edges.value.forEach((e) => {
1216
+ if (nodeMap.has(e.source) && nodeMap.has(e.target)) {
1217
+ outgoing.get(e.source).push(e.target);
1218
+ incoming.get(e.target).push(e.source);
1219
+ }
1220
+ });
1221
+
1222
+ const widthOf = (id) => nodeMap.get(id)?.dimensions?.width || nodeMap.get(id)?.width || DEFAULT_W;
1223
+ const heightOf = (id) => nodeMap.get(id)?.dimensions?.height || nodeMap.get(id)?.height || DEFAULT_H;
1224
+
1225
+ // 1. 分层:最长路径(有环时以 visiting 集合断环)
1226
+ const rank = new Map();
1227
+ const computeRank = (id, visiting) => {
1228
+ if (rank.has(id)) return rank.get(id);
1229
+ if (visiting.has(id)) return 0;
1230
+ visiting.add(id);
1231
+ const parents = incoming.get(id) || [];
1232
+ let r = 0;
1233
+ parents.forEach((p) => {
1234
+ r = Math.max(r, computeRank(p, visiting) + 1);
1235
+ });
1236
+ visiting.delete(id);
1237
+ rank.set(id, r);
1238
+ return r;
1239
+ };
1240
+ list.forEach((n) => computeRank(n.id, new Set()));
1241
+
1242
+ // 强制 start 到 rank 0,end 到最右 rank
1243
+ const startNode = list.find((n) => n.type === 'START');
1244
+ if (startNode) rank.set(startNode.id, 0);
1245
+ let maxR = 0;
1246
+ rank.forEach((r) => {
1247
+ if (r > maxR) maxR = r;
1248
+ });
1249
+ const endNode = list.find((n) => n.type === 'END');
1250
+ if (endNode) rank.set(endNode.id, Math.max(maxR, rank.get(endNode.id) ?? 0));
1251
+ rank.forEach((r) => {
1252
+ if (r > maxR) maxR = r;
1253
+ });
1254
+
1255
+ // 分组
1256
+ const ranks = new Map(); // rank -> [nodeId]
1257
+ list.forEach((n) => {
1258
+ const r = rank.get(n.id) ?? 0;
1259
+ if (!ranks.has(r)) ranks.set(r, []);
1260
+ ranks.get(r).push(n.id);
1261
+ });
1262
+ const rankKeys = [...ranks.keys()].sort((a, b) => a - b);
1263
+
1264
+ // 每层初始按原 y 排序(尽量保留用户当前顺序意图)
1265
+ ranks.forEach((ids) => {
1266
+ ids.sort((a, b) => (nodeMap.get(a).position?.y ?? 0) - (nodeMap.get(b).position?.y ?? 0));
1267
+ });
1268
+
1269
+ // 2. Barycenter 迭代减少交叉
1270
+ const orderInRank = new Map();
1271
+ const rebuildOrder = () => {
1272
+ orderInRank.clear();
1273
+ ranks.forEach((ids) => ids.forEach((id, i) => orderInRank.set(id, i)));
1274
+ };
1275
+ rebuildOrder();
1276
+
1277
+ const barySort = (ids, neighborsFn) => {
1278
+ const scored = ids.map((id) => {
1279
+ const nbrs = neighborsFn(id).filter((n) => orderInRank.has(n));
1280
+ const bary = nbrs.length === 0
1281
+ ? (orderInRank.get(id) ?? 0)
1282
+ : nbrs.reduce((s, n) => s + orderInRank.get(n), 0) / nbrs.length;
1283
+ return { id, bary };
1284
+ });
1285
+ scored.sort((a, b) => a.bary - b.bary);
1286
+ return scored.map((s) => s.id);
1287
+ };
1288
+
1289
+ for (let iter = 0; iter < 8; iter++) {
1290
+ for (let i = 1; i < rankKeys.length; i++) {
1291
+ const r = rankKeys[i];
1292
+ ranks.set(r, barySort(ranks.get(r), (id) => incoming.get(id) || []));
1293
+ }
1294
+ rebuildOrder();
1295
+ for (let i = rankKeys.length - 2; i >= 0; i--) {
1296
+ const r = rankKeys[i];
1297
+ ranks.set(r, barySort(ranks.get(r), (id) => outgoing.get(id) || []));
1298
+ }
1299
+ rebuildOrder();
1300
+ }
1301
+
1302
+ // 3. 定位:先算每层 X,再按父节点平均 Y 定 Y,最后推挤解重叠
1303
+ const rankX = new Map();
1304
+ let currentX = START_X;
1305
+ for (const r of rankKeys) {
1306
+ rankX.set(r, currentX);
1307
+ const maxW = Math.max(...ranks.get(r).map((id) => widthOf(id)));
1308
+ currentX += maxW + H_GAP;
1309
+ }
1310
+
1311
+ const positioned = new Map(); // nodeId -> { x, y, w, h }
1312
+
1313
+ for (const r of rankKeys) {
1314
+ const ids = ranks.get(r);
1315
+ const x = rankX.get(r);
1316
+ // 先算每个节点的目标 Y(父节点中心平均,无父就落 BASELINE)
1317
+ const targets = ids.map((id) => {
1318
+ const h = heightOf(id);
1319
+ const parents = (incoming.get(id) || []).filter((p) => positioned.has(p));
1320
+ let centerY;
1321
+ if (parents.length > 0) {
1322
+ const sum = parents.reduce((s, p) => {
1323
+ const pp = positioned.get(p);
1324
+ return s + pp.y + pp.h / 2;
1325
+ }, 0);
1326
+ centerY = sum / parents.length;
1327
+ } else {
1328
+ centerY = BASELINE_Y + h / 2;
1329
+ }
1330
+ return { id, w: widthOf(id), h, topY: centerY - h / 2 };
1331
+ });
1332
+
1333
+ // 按目标 Y 排序(严格保持在 barycenter 计算好的 order 之内会更少交叉,
1334
+ // 但目标 Y 排序在多数情况下与 order 一致,且能让连线更水平)
1335
+ targets.sort((a, b) => a.topY - b.topY);
1336
+
1337
+ // 顺序推挤:如果当前节点顶部小于上一个底部 + V_GAP,则下推
1338
+ let prevBottom = -Infinity;
1339
+ for (const t of targets) {
1340
+ if (t.topY < prevBottom + V_GAP) t.topY = prevBottom + V_GAP;
1341
+ positioned.set(t.id, { x, y: t.topY, w: t.w, h: t.h });
1342
+ prevBottom = t.topY + t.h;
1343
+ }
1344
+ }
1345
+
1346
+ // 4. 全局向 BASELINE_Y 居中(把当前 y 平均值移到 BASELINE_Y 附近,视觉更居中)
1347
+ const ys = [...positioned.values()].map((p) => p.y + p.h / 2);
1348
+ if (ys.length > 0) {
1349
+ const avg = ys.reduce((s, y) => s + y, 0) / ys.length;
1350
+ const dy = BASELINE_Y - avg;
1351
+ positioned.forEach((p) => (p.y += dy));
1352
+ }
1353
+
1354
+ // 5. 写回
1355
+ list.forEach((n) => {
1356
+ const p = positioned.get(n.id);
1357
+ if (p) n.position = { x: p.x, y: p.y };
1358
+ });
1359
+
1360
+ saveToHistory();
1361
+ }
1362
+
1363
+ /** 供宿主页面(外部 NodeConfigCard)回写节点数据用 */
1364
+ function patchNodeData(nodeId, dataPatch) {
1365
+ if (!nodeId || !dataPatch) return;
1366
+ const node = findNode(nodeId);
1367
+ if (!node) return;
1368
+ const merged = { ...node.data, ...dataPatch };
1369
+ updateNode(nodeId, { data: merged });
1370
+ if (selectedNode.value?.id === nodeId) {
1371
+ selectedNode.value = { ...selectedNode.value, data: merged };
1372
+ }
1373
+ }
1374
+
1375
+ /** 右键点节点:弹出上下文菜单 */
1376
+ function onNodeContextMenu(event: any) {
1377
+ if (props.readonly) return;
1378
+ event?.event?.preventDefault?.();
1379
+ const nativeEvent = event?.event as MouseEvent;
1380
+ contextMenu.value = {
1381
+ visible: true,
1382
+ x: nativeEvent.clientX,
1383
+ y: nativeEvent.clientY,
1384
+ nodeId: event?.node?.id ?? null,
1385
+ };
1386
+ selectedNode.value = event?.node ?? null;
1387
+ }
1388
+
1389
+ function closeContextMenu() {
1390
+ if (contextMenu.value) contextMenu.value.visible = false;
1391
+ }
1392
+
1393
+ function ctxDuplicate() {
1394
+ if (contextMenu.value?.nodeId) duplicateNodeById(contextMenu.value.nodeId);
1395
+ closeContextMenu();
1396
+ }
1397
+
1398
+ function ctxCopy() {
1399
+ if (contextMenu.value?.nodeId) copyNodeById(contextMenu.value.nodeId);
1400
+ closeContextMenu();
1401
+ }
1402
+
1403
+ function ctxPaste() {
1404
+ pasteNode();
1405
+ closeContextMenu();
1406
+ }
1407
+
1408
+ function ctxDelete() {
1409
+ if (contextMenu.value?.nodeId) deleteNodeById(contextMenu.value.nodeId);
1410
+ closeContextMenu();
1411
+ }
1412
+
1413
+ function ctxRun() {
1414
+ emit('run', { graph: getFlowInfo(), nodeId: contextMenu.value?.nodeId });
1415
+ closeContextMenu();
1416
+ }
1417
+
1418
+ /** 内部节点剪贴板(不同 FlowDesigner 实例互不干扰) */
1419
+ const clipboardNode = ref<any>(null);
1420
+
1421
+ function copyNodeById(nodeId: string) {
1422
+ const node = findNode(nodeId);
1423
+ if (!node) return;
1424
+ clipboardNode.value = langUtils.clone({
1425
+ type: node.type,
1426
+ data: node.data,
1427
+ position: node.position,
1428
+ });
1429
+ langUtils.message({ code: 0, message: '已复制节点' });
1430
+ }
1431
+
1432
+ function pasteNode(atPosition?: { x: number; y: number }) {
1433
+ if (!clipboardNode.value) return;
1434
+ const src = clipboardNode.value;
1435
+ const newId = langUtils.getId(src.type);
1436
+ const pos = atPosition ?? {
1437
+ x: (src.position?.x ?? 200) + 40,
1438
+ y: (src.position?.y ?? 200) + 40,
1439
+ };
1440
+ const newNode = {
1441
+ id: newId,
1442
+ type: src.type,
1443
+ position: pos,
1444
+ data: { ...langUtils.clone(src.data), id: newId },
1445
+ selected: false,
1446
+ };
1447
+ addNodes([newNode]);
1448
+ saveToHistory();
1449
+ resetGraph();
1450
+ }
1451
+
1452
+ /** 键盘快捷键:Del 删除、Ctrl+Z 撤销、Ctrl+Shift+Z / Ctrl+Y 重做 */
1453
+ function onKeydown(e: KeyboardEvent) {
1454
+ // 用户在输入框里打字时忽略
1455
+ const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
1456
+ if (
1457
+ tag === 'input' ||
1458
+ tag === 'textarea' ||
1459
+ (e.target as HTMLElement)?.isContentEditable
1460
+ ) {
1461
+ return;
1462
+ }
1463
+ const mod = e.ctrlKey || e.metaKey;
1464
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNode.value) {
1465
+ e.preventDefault();
1466
+ deleteNodeById(selectedNode.value.id);
1467
+ } else if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
1468
+ e.preventDefault();
1469
+ undo();
1470
+ } else if (
1471
+ (mod && e.key.toLowerCase() === 'y') ||
1472
+ (mod && e.shiftKey && e.key.toLowerCase() === 'z')
1473
+ ) {
1474
+ e.preventDefault();
1475
+ redo();
1476
+ } else if (mod && e.key.toLowerCase() === 'd' && selectedNode.value) {
1477
+ e.preventDefault();
1478
+ duplicateNodeById(selectedNode.value.id);
1479
+ } else if (mod && e.key.toLowerCase() === 'c' && selectedNode.value) {
1480
+ e.preventDefault();
1481
+ copyNodeById(selectedNode.value.id);
1482
+ } else if (mod && e.key.toLowerCase() === 'v' && clipboardNode.value) {
1483
+ e.preventDefault();
1484
+ pasteNode();
1485
+ }
1486
+ }
1487
+
1488
+ onMounted(() => window.addEventListener('keydown', onKeydown));
1489
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
1490
+
1491
+ defineExpose({
1492
+ getFlowInfo,
1493
+ reloadGraph,
1494
+ patchNodeData,
1495
+ deleteNodeById,
1496
+ duplicateNodeById,
1497
+ copyNodeById,
1498
+ pasteNode,
1499
+ undo,
1500
+ redo,
1501
+ validateWorkflow,
1502
+ });
1503
+ </script>
1504
+
1505
+ <template>
1506
+ <div class="dify-flow-designer" :class="{ 'wf-readonly': readonly }">
1507
+ <div class="main-layout">
1508
+ <!-- 中间流程设计区域 -->
1509
+ <div class="center-canvas">
1510
+ <!-- 顶部悬浮工具栏:撤销 / 重做 / 试运行 / 保存 / 发布 -->
1511
+ <div v-if="!readonly" class="wf-top-toolbar">
1512
+ <div class="wf-top-toolbar-group">
1513
+ <a-tooltip title="撤销 (Ctrl+Z)">
1514
+ <button
1515
+ class="wf-top-btn"
1516
+ :disabled="!canUndo"
1517
+ @click="undo"
1518
+ >
1519
+ <Icon name="undo" />
1520
+ </button>
1521
+ </a-tooltip>
1522
+ <a-tooltip title="重做 (Ctrl+Y)">
1523
+ <button
1524
+ class="wf-top-btn"
1525
+ :disabled="!canRedo"
1526
+ @click="redo"
1527
+ >
1528
+ <Icon name="redo" />
1529
+ </button>
1530
+ </a-tooltip>
1531
+ </div>
1532
+ <!-- 试运行 / 保存 / 发布 —— 只在独立宿主模式显示。抽屉宿主自己已经在
1533
+ 右上角提供了同名按钮,这里就不重复展示,避免用户不知道点哪一个。 -->
1534
+ <template v-if="!embedded">
1535
+ <span class="wf-top-toolbar-divider" />
1536
+ <div class="wf-top-toolbar-group">
1537
+ <a-tooltip title="试运行">
1538
+ <a-button size="small" :loading="running" @click="onRun">
1539
+ <template #icon>▶</template>
1540
+ 试运行
1541
+ </a-button>
1542
+ </a-tooltip>
1543
+ <a-tooltip title="保存草稿">
1544
+ <a-button size="small" :loading="saving" @click="onSaveDraft">
1545
+ 保存
1546
+ </a-button>
1547
+ </a-tooltip>
1548
+ <a-tooltip title="发布上线">
1549
+ <a-button
1550
+ type="primary"
1551
+ size="small"
1552
+ :loading="publishing"
1553
+ @click="onPublish"
1554
+ >
1555
+ 发布
1556
+ </a-button>
1557
+ </a-tooltip>
1558
+ </div>
1559
+ </template>
1560
+ </div>
1561
+
1562
+ <!-- 底部状态栏 -->
1563
+ <div v-if="!readonly" class="wf-bottom-status">
1564
+ <span class="wf-status-item">
1565
+ <span class="wf-status-label">节点</span>
1566
+ <span class="wf-status-value">{{ nodes.length }}</span>
1567
+ </span>
1568
+ <span class="wf-status-item">
1569
+ <span class="wf-status-label">连线</span>
1570
+ <span class="wf-status-value">{{ edges.length }}</span>
1571
+ </span>
1572
+ <span class="wf-status-item">
1573
+ <span class="wf-status-label">缩放</span>
1574
+ <span class="wf-status-value">
1575
+ {{ Math.round((getViewport().zoom || 1) * 100) }}%
1576
+ </span>
1577
+ </span>
1578
+ </div>
1579
+
1580
+ <VueFlow
1581
+ v-model:nodes="nodes"
1582
+ v-model:edges="edges"
1583
+ :default-viewport="defaultViewport"
1584
+ :min-zoom="0.1"
1585
+ :max-zoom="2"
1586
+ :snap-to-grid="true"
1587
+ :snap-grid="[16, 16]"
1588
+ :nodes-draggable="!readonly"
1589
+ :nodes-connectable="!readonly"
1590
+ :elements-selectable="!readonly"
1591
+ fit-view-on-init
1592
+ @nodes-change="onNodesChange"
1593
+ @edges-change="onEdgesChange"
1594
+ @connect="onConnect"
1595
+ @node-click="onNodeClick"
1596
+ @node-context-menu="onNodeContextMenu"
1597
+ @node-mouse-enter="onNodeMouseEnter"
1598
+ @node-mouse-leave="onNodeMouseLeave"
1599
+ @edge-click="onEdgeClick"
1600
+ @pane-click="(e) => { closeContextMenu(); onPaneClick(); }"
1601
+ class="dify-flow"
1602
+ >
1603
+ <!-- 节点模板 —— slot 名与 backend NodeType 枚举 / 存储层 n.type
1604
+ 保持一致(UPPER_SNAKE),画布 / 存储 / 引擎单一标识贯穿。 -->
1605
+ <template #node-START="props">
1606
+ <StartNode
1607
+ v-bind="props"
1608
+ @connection-plus-click="onConnectionPlusClick"
1609
+ @duplicate="duplicateNodeById"
1610
+ @delete="deleteNodeById"
1611
+ />
1612
+ </template>
1613
+ <template #node-USER_INPUT="props">
1614
+ <UserInputNode
1615
+ v-bind="props"
1616
+ @connection-plus-click="onConnectionPlusClick"
1617
+ @duplicate="duplicateNodeById"
1618
+ @delete="deleteNodeById"
1619
+ />
1620
+ </template>
1621
+ <template #node-FILE_UPLOAD="props">
1622
+ <FileUploadNode
1623
+ v-bind="props"
1624
+ @connection-plus-click="onConnectionPlusClick"
1625
+ @duplicate="duplicateNodeById"
1626
+ @delete="deleteNodeById"
1627
+ />
1628
+ </template>
1629
+ <template #node-LLM="props">
1630
+ <LLMNode
1631
+ v-bind="props"
1632
+ @connection-plus-click="onConnectionPlusClick"
1633
+ @duplicate="duplicateNodeById"
1634
+ @delete="deleteNodeById"
1635
+ />
1636
+ </template>
1637
+ <template #node-AGENT="props">
1638
+ <AgentNode
1639
+ v-bind="props"
1640
+ @connection-plus-click="onConnectionPlusClick"
1641
+ @duplicate="duplicateNodeById"
1642
+ @delete="deleteNodeById"
1643
+ />
1644
+ </template>
1645
+ <template #node-KNOWLEDGE_RETRIEVAL="props">
1646
+ <KnowledgeRetrievalNode
1647
+ v-bind="props"
1648
+ @connection-plus-click="onConnectionPlusClick"
1649
+ @duplicate="duplicateNodeById"
1650
+ @delete="deleteNodeById"
1651
+ />
1652
+ </template>
1653
+ <template #node-QUESTION_CLASSIFIER="props">
1654
+ <ClassifierNode
1655
+ v-bind="props"
1656
+ @connection-plus-click="onConnectionPlusClick"
1657
+ @duplicate="duplicateNodeById"
1658
+ @delete="deleteNodeById"
1659
+ />
1660
+ </template>
1661
+ <template #node-IF_ELSE="props">
1662
+ <ConditionNode
1663
+ v-bind="props"
1664
+ @connection-plus-click="onConnectionPlusClick"
1665
+ @duplicate="duplicateNodeById"
1666
+ @delete="deleteNodeById"
1667
+ />
1668
+ </template>
1669
+ <template #node-LOOP="props">
1670
+ <LoopNode
1671
+ v-bind="props"
1672
+ @connection-plus-click="onConnectionPlusClick"
1673
+ @duplicate="duplicateNodeById"
1674
+ @delete="deleteNodeById"
1675
+ />
1676
+ </template>
1677
+ <template #node-VARIABLE_AGGREGATOR="props">
1678
+ <VariableNode
1679
+ v-bind="props"
1680
+ @connection-plus-click="onConnectionPlusClick"
1681
+ @duplicate="duplicateNodeById"
1682
+ @delete="deleteNodeById"
1683
+ />
1684
+ </template>
1685
+ <template #node-HTTP_REQUEST="props">
1686
+ <HttpNode
1687
+ v-bind="props"
1688
+ @connection-plus-click="onConnectionPlusClick"
1689
+ @duplicate="duplicateNodeById"
1690
+ @delete="deleteNodeById"
1691
+ />
1692
+ </template>
1693
+ <template #node-SERVICE_API="props">
1694
+ <ServiceApiNode
1695
+ v-bind="props"
1696
+ @connection-plus-click="onConnectionPlusClick"
1697
+ @duplicate="duplicateNodeById"
1698
+ @delete="deleteNodeById"
1699
+ />
1700
+ </template>
1701
+ <template #node-CODE="props">
1702
+ <CodeNode
1703
+ v-bind="props"
1704
+ @connection-plus-click="onConnectionPlusClick"
1705
+ @duplicate="duplicateNodeById"
1706
+ @delete="deleteNodeById"
1707
+ />
1708
+ </template>
1709
+ <template #node-TEMPLATE_TRANSFORM="props">
1710
+ <TemplateNode
1711
+ v-bind="props"
1712
+ @connection-plus-click="onConnectionPlusClick"
1713
+ @duplicate="duplicateNodeById"
1714
+ @delete="deleteNodeById"
1715
+ />
1716
+ </template>
1717
+ <template #node-ANSWER="props">
1718
+ <AnswerNode
1719
+ v-bind="props"
1720
+ @connection-plus-click="onConnectionPlusClick"
1721
+ @duplicate="duplicateNodeById"
1722
+ @delete="deleteNodeById"
1723
+ />
1724
+ </template>
1725
+ <template #node-END="props">
1726
+ <EndNode
1727
+ v-bind="props"
1728
+ @connection-plus-click="onConnectionPlusClick"
1729
+ @duplicate="duplicateNodeById"
1730
+ @delete="deleteNodeById"
1731
+ />
1732
+ </template>
1733
+ <template #node-ITERATION="props">
1734
+ <IterationNode
1735
+ v-bind="props"
1736
+ @connection-plus-click="onConnectionPlusClick"
1737
+ @duplicate="duplicateNodeById"
1738
+ @delete="deleteNodeById"
1739
+ />
1740
+ </template>
1741
+ <template #node-PARAMETER_EXTRACTOR="props">
1742
+ <ParameterExtractorNode
1743
+ v-bind="props"
1744
+ @connection-plus-click="onConnectionPlusClick"
1745
+ @duplicate="duplicateNodeById"
1746
+ @delete="deleteNodeById"
1747
+ />
1748
+ </template>
1749
+ <template #node-LIST_OPERATOR="props">
1750
+ <ListOperatorNode
1751
+ v-bind="props"
1752
+ @connection-plus-click="onConnectionPlusClick"
1753
+ @duplicate="duplicateNodeById"
1754
+ @delete="deleteNodeById"
1755
+ />
1756
+ </template>
1757
+ <template #node-DOCUMENT_EXTRACTOR="props">
1758
+ <DocumentExtractorNode
1759
+ v-bind="props"
1760
+ @connection-plus-click="onConnectionPlusClick"
1761
+ @duplicate="duplicateNodeById"
1762
+ @delete="deleteNodeById"
1763
+ />
1764
+ </template>
1765
+ <template #node-HUMAN_INPUT="props">
1766
+ <HumanInputNode
1767
+ v-bind="props"
1768
+ @connection-plus-click="onConnectionPlusClick"
1769
+ @duplicate="duplicateNodeById"
1770
+ @delete="deleteNodeById"
1771
+ />
1772
+ </template>
1773
+ <template #node-VARIABLE_ASSIGNER="props">
1774
+ <VariableAssignerNode
1775
+ v-bind="props"
1776
+ @connection-plus-click="onConnectionPlusClick"
1777
+ @duplicate="duplicateNodeById"
1778
+ @delete="deleteNodeById"
1779
+ />
1780
+ </template>
1781
+
1782
+ <!-- 背景 -->
1783
+ <Background pattern-color="#e5e7eb" :gap="16" />
1784
+
1785
+ <!-- 缩放控件(右下角悬浮) -->
1786
+ <div class="wf-zoom-cluster">
1787
+ <a-tooltip title="放大">
1788
+ <button class="wf-zoom-btn" @click="zoomIn">+</button>
1789
+ </a-tooltip>
1790
+ <a-tooltip title="缩小">
1791
+ <button class="wf-zoom-btn" @click="zoomOut">-</button>
1792
+ </a-tooltip>
1793
+ <a-tooltip title="适应画布">
1794
+ <button class="wf-zoom-btn" @click="fitView">
1795
+ <Icon name="fit" />
1796
+ </button>
1797
+ </a-tooltip>
1798
+ <a-tooltip title="自动排布">
1799
+ <button class="wf-zoom-btn" @click="arrangeNodes">
1800
+ <Icon name="arrange" />
1801
+ </button>
1802
+ </a-tooltip>
1803
+ </div>
1804
+
1805
+ <!-- 小地图 -->
1806
+ <MiniMap
1807
+ :node-color="getNodeColor"
1808
+ mask-color="rgba(15,23,42,0.06)"
1809
+ class="wf-minimap"
1810
+ position="bottom-right"
1811
+ />
1812
+
1813
+ <!-- 自定义边类型 -->
1814
+ <template #edge-custom="edgeProps">
1815
+ <CustomEdge
1816
+ v-bind="edgeProps"
1817
+ />
1818
+ </template>
1819
+ </VueFlow>
1820
+
1821
+ <!-- 节点右键上下文菜单 -->
1822
+ <div
1823
+ v-if="contextMenu && contextMenu.visible"
1824
+ class="wf-context-menu"
1825
+ :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
1826
+ @click.stop
1827
+ >
1828
+ <div class="wf-context-menu-item" @click="ctxRun">
1829
+ 运行到此节点
1830
+ </div>
1831
+ <div class="wf-context-menu-divider" />
1832
+ <div class="wf-context-menu-item" @click="ctxCopy">
1833
+ 复制
1834
+ <span class="wf-context-menu-shortcut">Ctrl+C</span>
1835
+ </div>
1836
+ <div
1837
+ class="wf-context-menu-item"
1838
+ :class="{ 'wf-context-menu-item-disabled': !clipboardNode }"
1839
+ @click="clipboardNode && ctxPaste()"
1840
+ >
1841
+ 粘贴
1842
+ <span class="wf-context-menu-shortcut">Ctrl+V</span>
1843
+ </div>
1844
+ <div class="wf-context-menu-item" @click="ctxDuplicate">
1845
+ 复制副本
1846
+ <span class="wf-context-menu-shortcut">Ctrl+D</span>
1847
+ </div>
1848
+ <div class="wf-context-menu-divider" />
1849
+ <div
1850
+ class="wf-context-menu-item wf-context-menu-item-danger"
1851
+ @click="ctxDelete"
1852
+ >
1853
+ 删除节点
1854
+ <span class="wf-context-menu-shortcut">Del</span>
1855
+ </div>
1856
+ </div>
1857
+ </div>
1858
+
1859
+ <!-- 右侧配置面板 -->
1860
+ <!-- <div class="right-panel" v-if="selectedNode">
1861
+ <div class="panel-header">
1862
+ <h3>{{ getNodeTypeLabel(selectedNode.type) }}</h3>
1863
+ <button @click="selectedNode = null" class="close-btn">
1864
+ <Icon name="close" />
1865
+ </button>
1866
+ </div>
1867
+ <div class="panel-content">
1868
+ <NodeConfigCard
1869
+ :node="selectedNode"
1870
+ @update="updateNodeData"
1871
+ @delete="deleteNode"
1872
+ />
1873
+ </div>
1874
+ </div>-->
1875
+ </div>
1876
+
1877
+ <!-- 节点选择器面板 -->
1878
+ <div
1879
+ v-if="showNodeSelector"
1880
+ class="wf-selector-container"
1881
+ :style="{
1882
+ left: `${nodeSelectorPosition.x}px`,
1883
+ top: `${nodeSelectorPosition.y}px`,
1884
+ }"
1885
+ >
1886
+ <!-- 左侧节点列表 -->
1887
+ <div class="wf-selector-panel">
1888
+ <!-- 可拖动的头部 -->
1889
+ <div class="wf-selector-header" @mousedown="onSelectorHeaderMouseDown">
1890
+ <div class="wf-selector-drag-handle" title="按住可拖动">
1891
+ <span class="wf-selector-drag-dots"></span>
1892
+ </div>
1893
+ <a-input
1894
+ v-model:value="nodeSearchQuery"
1895
+ placeholder="搜索节点"
1896
+ size="small"
1897
+ allow-clear
1898
+ style="flex: 1"
1899
+ @mousedown.stop
1900
+ @keydown.stop
1901
+ />
1902
+ <button
1903
+ class="wf-selector-close-btn"
1904
+ title="关闭"
1905
+ @click.stop="closeNodeSelector"
1906
+ >
1907
+ <Icon name="close" />
1908
+ </button>
1909
+ </div>
1910
+ <div class="wf-selector-content">
1911
+ <div
1912
+ v-if="filteredCategories.length === 0"
1913
+ class="wf-selector-empty"
1914
+ >
1915
+ 没有匹配的节点
1916
+ </div>
1917
+ <div
1918
+ v-for="category in filteredCategories"
1919
+ :key="category.title"
1920
+ class="wf-selector-category"
1921
+ >
1922
+ <div class="wf-selector-category-title">
1923
+ <Icon :name="category.icon" />
1924
+ <span>{{ category.title }}</span>
1925
+ </div>
1926
+ <div class="wf-selector-nodes">
1927
+ <div
1928
+ v-for="node in category.nodes"
1929
+ :key="node.type"
1930
+ class="wf-selector-node"
1931
+ @click="onNodeSelectorSelect(node.type)"
1932
+ @mouseenter="onNodeItemMouseEnter($event, node)"
1933
+ @mouseleave="onNodeItemMouseLeave"
1934
+ >
1935
+ <div
1936
+ class="wf-selector-node-icon"
1937
+ :class="`icon-${node.type}`"
1938
+ >
1939
+ <Icon :name="node.icon" />
1940
+ </div>
1941
+ <div class="wf-selector-node-label">{{ node.label }}</div>
1942
+ </div>
1943
+ </div>
1944
+ </div>
1945
+ </div>
1946
+ </div>
1947
+
1948
+ <!-- 右侧预览卡片 -->
1949
+ <div
1950
+ v-if="hoveredNodeType"
1951
+ class="wf-selector-preview"
1952
+ :style="{
1953
+ top: `${hoveredNodeType.position?.y || 0}px`,
1954
+ }"
1955
+ >
1956
+ <div class="wf-selector-preview-header">
1957
+ <div
1958
+ class="wf-selector-node-icon"
1959
+ :class="`icon-${hoveredNodeType.type}`"
1960
+ >
1961
+ <Icon :name="hoveredNodeType.icon" />
1962
+ </div>
1963
+ <div class="wf-selector-preview-title">
1964
+ {{ hoveredNodeType.label }}
1965
+ </div>
1966
+ </div>
1967
+ <div class="wf-selector-preview-desc">
1968
+ {{ hoveredNodeType.description }}
1969
+ </div>
1970
+ </div>
1971
+ </div>
1972
+
1973
+ <!-- 遮罩层 -->
1974
+ <div
1975
+ v-if="showNodeSelector"
1976
+ class="wf-selector-overlay"
1977
+ @click="closeNodeSelector"
1978
+ ></div>
1979
+ </div>
1980
+ </template>
1981
+
1982
+ <style scoped>
1983
+ .dify-flow-designer {
1984
+ width: 100%;
1985
+ height: 100%;
1986
+ min-height: 0;
1987
+ display: flex;
1988
+ flex-direction: column;
1989
+ }
1990
+
1991
+ /* 顶部工具栏 */
1992
+ .top-toolbar {
1993
+ display: flex;
1994
+ justify-content: space-between;
1995
+ align-items: center;
1996
+ padding: 0 16px;
1997
+ height: 60px;
1998
+ border-bottom: 1px solid #e5e7eb;
1999
+ flex-shrink: 0;
2000
+ }
2001
+
2002
+ .toolbar-left,
2003
+ .toolbar-center,
2004
+ .toolbar-right {
2005
+ display: flex;
2006
+ align-items: center;
2007
+ gap: 12px;
2008
+ }
2009
+
2010
+ .logo-section {
2011
+ display: flex;
2012
+ align-items: center;
2013
+ gap: 8px;
2014
+ }
2015
+
2016
+ .app-title {
2017
+ font-size: 18px;
2018
+ font-weight: 600;
2019
+ color: #1f2937;
2020
+ }
2021
+
2022
+ .btn {
2023
+ display: flex;
2024
+ align-items: center;
2025
+ gap: 6px;
2026
+ padding: 8px 16px;
2027
+ border: none;
2028
+ border-radius: 6px;
2029
+ font-size: 14px;
2030
+ font-weight: 500;
2031
+ cursor: pointer;
2032
+ transition: all 0.2s;
2033
+ }
2034
+
2035
+ .btn-run {
2036
+ background: #10b981;
2037
+ color: white;
2038
+ }
2039
+
2040
+ .btn-run:hover {
2041
+ background: #059669;
2042
+ }
2043
+
2044
+ .btn-debug {
2045
+ background: #f59e0b;
2046
+ color: white;
2047
+ }
2048
+
2049
+ .btn-debug:hover {
2050
+ background: #d97706;
2051
+ }
2052
+
2053
+ .btn-publish {
2054
+ background: #6366f1;
2055
+ color: white;
2056
+ }
2057
+
2058
+ .btn-publish:hover {
2059
+ background: #4f46e5;
2060
+ }
2061
+
2062
+ .btn-save {
2063
+ background: #e5e7eb;
2064
+ color: #374151;
2065
+ }
2066
+
2067
+ .btn-save:hover {
2068
+ background: #d1d5db;
2069
+ }
2070
+
2071
+ .btn-share {
2072
+ background: #3b82f6;
2073
+ color: white;
2074
+ }
2075
+
2076
+ .btn-share:hover {
2077
+ background: #2563eb;
2078
+ }
2079
+
2080
+ /* 主布局 */
2081
+ .main-layout {
2082
+ display: flex;
2083
+ flex: 1;
2084
+ min-height: 0; /* 允许在列 flex 中被压缩,防止子级 100% 高度被撑满溢出 */
2085
+ overflow: hidden;
2086
+ }
2087
+
2088
+ /* 左侧面板 */
2089
+ .left-panel {
2090
+ width: 280px;
2091
+ background: white;
2092
+ border-right: 1px solid #e5e7eb;
2093
+ display: flex;
2094
+ flex-direction: column;
2095
+ overflow: hidden;
2096
+ }
2097
+
2098
+ .panel-header {
2099
+ padding: 16px;
2100
+ border-bottom: 1px solid #e5e7eb;
2101
+ }
2102
+
2103
+ .panel-header h3 {
2104
+ margin: 0;
2105
+ font-size: 16px;
2106
+ font-weight: 600;
2107
+ color: #1f2937;
2108
+ }
2109
+
2110
+ .node-categories {
2111
+ flex: 1;
2112
+ overflow-y: auto;
2113
+ padding: 16px;
2114
+ }
2115
+
2116
+ .category-section {
2117
+ margin-bottom: 14px;
2118
+ }
2119
+
2120
+ .category-title {
2121
+ display: flex;
2122
+ align-items: center;
2123
+ gap: 8px;
2124
+ font-size: 14px;
2125
+ font-weight: 600;
2126
+ color: #4b5563;
2127
+ margin-bottom: 12px;
2128
+ }
2129
+
2130
+ .node-items {
2131
+ display: flex;
2132
+ flex-direction: column;
2133
+ gap: 8px;
2134
+ }
2135
+
2136
+ .node-item {
2137
+ display: flex;
2138
+ align-items: center;
2139
+ gap: 8px;
2140
+ padding: 8px 12px;
2141
+ /* border: 1px solid #e5e7eb; */
2142
+ border-radius: 6px;
2143
+ cursor: pointer;
2144
+ transition: all 0.2s;
2145
+ background: white;
2146
+ }
2147
+
2148
+ .node-item:hover {
2149
+ border-color: #6366f1;
2150
+ background: #f8fafc;
2151
+ }
2152
+
2153
+ .node-item span {
2154
+ font-size: 14px;
2155
+ color: #374151;
2156
+ }
2157
+
2158
+ /* 中间画布 */
2159
+ .center-canvas {
2160
+ flex: 1;
2161
+ position: relative;
2162
+ background: #f9fafb;
2163
+ }
2164
+
2165
+ .dify-flow {
2166
+ width: 100%;
2167
+ height: 100%;
2168
+ background: #f8fafc;
2169
+ }
2170
+
2171
+ /* 顶部悬浮工具栏(保存 / 发布 / 试运行 / 撤销 / 重做) */
2172
+ .wf-top-toolbar {
2173
+ position: absolute;
2174
+ top: 12px;
2175
+ right: 12px;
2176
+ z-index: 10;
2177
+ display: flex;
2178
+ align-items: center;
2179
+ gap: 6px;
2180
+ padding: 6px 8px;
2181
+ background: rgba(255, 255, 255, 0.97);
2182
+ border: 1px solid #e5e7eb;
2183
+ border-radius: 10px;
2184
+ box-shadow: 0 4px 16px rgba(15, 23, 42, 0.08);
2185
+ backdrop-filter: blur(8px);
2186
+ }
2187
+
2188
+ .wf-top-toolbar-group {
2189
+ display: flex;
2190
+ align-items: center;
2191
+ gap: 4px;
2192
+ }
2193
+
2194
+ .wf-top-btn {
2195
+ display: flex;
2196
+ align-items: center;
2197
+ justify-content: center;
2198
+ width: 28px;
2199
+ height: 28px;
2200
+ color: #4b5563;
2201
+ background: transparent;
2202
+ border: none;
2203
+ border-radius: 6px;
2204
+ cursor: pointer;
2205
+ transition: background 0.15s, color 0.15s;
2206
+ }
2207
+
2208
+ .wf-top-btn:hover:not(:disabled) {
2209
+ background: #f3f4f6;
2210
+ color: #1f2937;
2211
+ }
2212
+
2213
+ .wf-top-btn:disabled {
2214
+ color: #d1d5db;
2215
+ cursor: not-allowed;
2216
+ }
2217
+
2218
+ .wf-top-btn :deep(.icon) {
2219
+ width: 16px;
2220
+ height: 16px;
2221
+ }
2222
+
2223
+ .wf-top-toolbar-divider {
2224
+ width: 1px;
2225
+ height: 20px;
2226
+ margin: 0 2px;
2227
+ background: #e5e7eb;
2228
+ }
2229
+
2230
+ /* 底部状态栏 */
2231
+ .wf-bottom-status {
2232
+ position: absolute;
2233
+ bottom: 12px;
2234
+ left: 12px;
2235
+ z-index: 10;
2236
+ display: flex;
2237
+ align-items: center;
2238
+ gap: 12px;
2239
+ padding: 5px 10px;
2240
+ background: rgba(255, 255, 255, 0.95);
2241
+ border: 1px solid #e5e7eb;
2242
+ border-radius: 8px;
2243
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
2244
+ backdrop-filter: blur(6px);
2245
+ font-size: 11px;
2246
+ }
2247
+
2248
+ .wf-status-item {
2249
+ display: flex;
2250
+ align-items: center;
2251
+ gap: 4px;
2252
+ }
2253
+
2254
+ .wf-status-label {
2255
+ color: #9ca3af;
2256
+ }
2257
+
2258
+ .wf-status-value {
2259
+ font-weight: 600;
2260
+ color: #4b5563;
2261
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
2262
+ }
2263
+
2264
+ /* 节点右键上下文菜单 */
2265
+ .wf-context-menu {
2266
+ position: fixed;
2267
+ z-index: 20;
2268
+ min-width: 160px;
2269
+ padding: 4px 0;
2270
+ background: #ffffff;
2271
+ border: 1px solid #e5e7eb;
2272
+ border-radius: 8px;
2273
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
2274
+ }
2275
+
2276
+ .wf-context-menu-item {
2277
+ display: flex;
2278
+ align-items: center;
2279
+ justify-content: space-between;
2280
+ gap: 24px;
2281
+ padding: 8px 12px;
2282
+ font-size: 13px;
2283
+ color: #1f2937;
2284
+ cursor: pointer;
2285
+ transition: background 0.15s;
2286
+ }
2287
+
2288
+ .wf-context-menu-item:hover {
2289
+ background: #f3f4f6;
2290
+ }
2291
+
2292
+ .wf-context-menu-item-danger {
2293
+ color: #dc2626;
2294
+ }
2295
+
2296
+ .wf-context-menu-item-danger:hover {
2297
+ background: #fef2f2;
2298
+ }
2299
+
2300
+ .wf-context-menu-item-disabled {
2301
+ color: #d1d5db;
2302
+ cursor: not-allowed;
2303
+ }
2304
+
2305
+ .wf-context-menu-item-disabled:hover {
2306
+ background: transparent;
2307
+ }
2308
+
2309
+ .wf-context-menu-divider {
2310
+ height: 1px;
2311
+ margin: 4px 0;
2312
+ background: #f3f4f6;
2313
+ }
2314
+
2315
+ .wf-context-menu-shortcut {
2316
+ color: #9ca3af;
2317
+ font-size: 12px;
2318
+ }
2319
+
2320
+ /* 遗留:.right-panel / .panel-header / .close-btn 相关 CSS 已随内嵌右侧面板一起移除,
2321
+ * 右侧配置面板现在由宿主页面控制,不再是 FlowDesigner 内部结构 */
2322
+
2323
+ /* Vue Flow 样式覆盖 */
2324
+ .dify-flow :deep(.vue-flow__background) {
2325
+ /*background-color: #1f1f1f;*/
2326
+ }
2327
+
2328
+ .dify-flow :deep(.vue-flow__controls) {
2329
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
2330
+ }
2331
+
2332
+ .dify-flow :deep(.vue-flow__minimap) {
2333
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
2334
+ }
2335
+
2336
+ .dify-flow :deep(.vue-flow__edge) {
2337
+ z-index: 1000;
2338
+ }
2339
+
2340
+ .dify-flow :deep(.vue-flow__edge-path) {
2341
+ stroke: #6366f1;
2342
+ stroke-width: 2;
2343
+ fill: none;
2344
+ }
2345
+
2346
+ .dify-flow :deep(.vue-flow__edge.animated .vue-flow__edge-path) {
2347
+ stroke-dasharray: 5;
2348
+ animation: dashdraw 0.5s linear infinite;
2349
+ }
2350
+
2351
+ .dify-flow :deep(.vue-flow__edge:hover .vue-flow__edge-path) {
2352
+ stroke: #4f46e5;
2353
+ stroke-width: 3;
2354
+ }
2355
+
2356
+ .dify-flow :deep(.vue-flow__connection-line) {
2357
+ stroke: #6366f1;
2358
+ stroke-width: 2;
2359
+ stroke-dasharray: 5, 5;
2360
+ }
2361
+
2362
+ .dify-flow :deep(.vue-flow__handle) {
2363
+ width: 12px;
2364
+ height: 12px;
2365
+ border: 2px solid #ffffff;
2366
+ border-radius: 50%;
2367
+ }
2368
+
2369
+ .dify-flow :deep(.vue-flow__handle.connectingfrom) {
2370
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.3);
2371
+ }
2372
+
2373
+ .dify-flow :deep(.vue-flow__handle.valid) {
2374
+ box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.3);
2375
+ }
2376
+
2377
+ @keyframes dashdraw {
2378
+ to {
2379
+ stroke-dashoffset: -10;
2380
+ }
2381
+ }
2382
+
2383
+ /* 节点选择器面板样式 */
2384
+ .wf-selector-overlay {
2385
+ position: fixed;
2386
+ top: 0;
2387
+ left: 0;
2388
+ right: 0;
2389
+ bottom: 0;
2390
+ background: rgba(15, 23, 42, 0.15);
2391
+ z-index: 9999;
2392
+ }
2393
+
2394
+ .wf-selector-container {
2395
+ position: fixed;
2396
+ display: flex;
2397
+ gap: 8px;
2398
+ z-index: 10000;
2399
+ }
2400
+
2401
+ .wf-selector-panel {
2402
+ display: flex;
2403
+ flex-direction: column;
2404
+ width: 280px;
2405
+ max-height: min(70vh, 600px);
2406
+ background: #ffffff;
2407
+ border: 1px solid #e5e7eb;
2408
+ border-radius: 10px;
2409
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.2);
2410
+ overflow: hidden;
2411
+ }
2412
+
2413
+ .wf-selector-header {
2414
+ display: flex;
2415
+ align-items: center;
2416
+ gap: 6px;
2417
+ padding: 8px;
2418
+ background: #f9fafb;
2419
+ border-bottom: 1px solid #f0f0f0;
2420
+ cursor: grab;
2421
+ user-select: none;
2422
+ }
2423
+
2424
+ .wf-selector-header:active {
2425
+ cursor: grabbing;
2426
+ }
2427
+
2428
+ .wf-selector-drag-handle {
2429
+ display: flex;
2430
+ align-items: center;
2431
+ justify-content: center;
2432
+ flex: none;
2433
+ width: 14px;
2434
+ height: 20px;
2435
+ color: #9ca3af;
2436
+ }
2437
+
2438
+ .wf-selector-drag-dots {
2439
+ position: relative;
2440
+ width: 6px;
2441
+ height: 10px;
2442
+ background:
2443
+ radial-gradient(circle at 25% 25%, currentColor 1px, transparent 1.5px),
2444
+ radial-gradient(circle at 75% 25%, currentColor 1px, transparent 1.5px),
2445
+ radial-gradient(circle at 25% 75%, currentColor 1px, transparent 1.5px),
2446
+ radial-gradient(circle at 75% 75%, currentColor 1px, transparent 1.5px);
2447
+ background-size: 100% 100%;
2448
+ }
2449
+
2450
+ .wf-selector-close-btn {
2451
+ display: flex;
2452
+ align-items: center;
2453
+ justify-content: center;
2454
+ flex: none;
2455
+ width: 24px;
2456
+ height: 24px;
2457
+ color: #6b7280;
2458
+ background: transparent;
2459
+ border: none;
2460
+ border-radius: 4px;
2461
+ cursor: pointer;
2462
+ transition: background 0.15s;
2463
+ }
2464
+
2465
+ .wf-selector-close-btn:hover {
2466
+ background: #e5e7eb;
2467
+ color: #1f2937;
2468
+ }
2469
+
2470
+ .wf-selector-content {
2471
+ flex: 1;
2472
+ min-height: 0;
2473
+ padding: 8px 10px;
2474
+ overflow-y: auto;
2475
+ }
2476
+
2477
+ .wf-selector-empty {
2478
+ padding: 20px 8px;
2479
+ text-align: center;
2480
+ font-size: 12px;
2481
+ color: #9ca3af;
2482
+ }
2483
+
2484
+ .wf-selector-category {
2485
+ margin-bottom: 12px;
2486
+ }
2487
+
2488
+ .wf-selector-category:last-child {
2489
+ margin-bottom: 0;
2490
+ }
2491
+
2492
+ .wf-selector-category-title {
2493
+ display: flex;
2494
+ align-items: center;
2495
+ gap: 6px;
2496
+ padding: 4px 6px;
2497
+ font-size: 12px;
2498
+ font-weight: 600;
2499
+ color: #6b7280;
2500
+ text-transform: uppercase;
2501
+ letter-spacing: 0.4px;
2502
+ }
2503
+
2504
+ .wf-selector-nodes {
2505
+ display: flex;
2506
+ flex-direction: column;
2507
+ gap: 2px;
2508
+ }
2509
+
2510
+ .wf-selector-node {
2511
+ display: flex;
2512
+ align-items: center;
2513
+ gap: 8px;
2514
+ padding: 6px 8px;
2515
+ border-radius: 6px;
2516
+ cursor: pointer;
2517
+ transition: background 0.15s;
2518
+ }
2519
+
2520
+ .wf-selector-node:hover {
2521
+ background: #eef2ff;
2522
+ }
2523
+
2524
+ .wf-selector-node-icon {
2525
+ display: flex;
2526
+ align-items: center;
2527
+ justify-content: center;
2528
+ flex: none;
2529
+ width: 24px;
2530
+ height: 24px;
2531
+ border-radius: 6px;
2532
+ }
2533
+
2534
+ .wf-selector-node-icon :deep(.icon) {
2535
+ width: 14px;
2536
+ height: 14px;
2537
+ }
2538
+
2539
+ .wf-selector-node-label {
2540
+ font-size: 13px;
2541
+ color: #1f2937;
2542
+ }
2543
+
2544
+ .wf-selector-preview {
2545
+ position: absolute;
2546
+ left: 288px;
2547
+ width: 260px;
2548
+ padding: 12px;
2549
+ background: #ffffff;
2550
+ border: 1px solid #e5e7eb;
2551
+ border-radius: 8px;
2552
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
2553
+ transform: translateY(-8px);
2554
+ pointer-events: none;
2555
+ }
2556
+
2557
+ .wf-selector-preview-header {
2558
+ display: flex;
2559
+ align-items: center;
2560
+ gap: 8px;
2561
+ margin-bottom: 6px;
2562
+ }
2563
+
2564
+ .wf-selector-preview-title {
2565
+ font-size: 13px;
2566
+ font-weight: 600;
2567
+ color: #1f2937;
2568
+ }
2569
+
2570
+ .wf-selector-preview-desc {
2571
+ font-size: 12px;
2572
+ line-height: 1.4;
2573
+ color: #6b7280;
2574
+ }
2575
+
2576
+ /* 彩色图标样式 —— class 名对齐当前 UPPER_SNAKE 的 node.type
2577
+ * (模板里是 `icon-${node.type}`),调色板沿用旧版本,避免面板被显示成一片黑。 */
2578
+ .icon-START {
2579
+ background: #dcfce7;
2580
+ color: #16a34a;
2581
+ }
2582
+ .icon-USER_INPUT {
2583
+ background: #dbeafe;
2584
+ color: #2563eb;
2585
+ }
2586
+ .icon-FILE_UPLOAD {
2587
+ background: #f3e8ff;
2588
+ color: #9333ea;
2589
+ }
2590
+ .icon-LLM {
2591
+ background: #dbeafe;
2592
+ color: #2563eb;
2593
+ }
2594
+ .icon-AGENT {
2595
+ background: #ede9fe;
2596
+ color: #7c3aed;
2597
+ }
2598
+ .icon-KNOWLEDGE_RETRIEVAL {
2599
+ background: #f3e8ff;
2600
+ color: #9333ea;
2601
+ }
2602
+ .icon-QUESTION_CLASSIFIER {
2603
+ background: #fef3c7;
2604
+ color: #d97706;
2605
+ }
2606
+ .icon-IF_ELSE {
2607
+ background: #fef3c7;
2608
+ color: #d97706;
2609
+ }
2610
+ .icon-LOOP {
2611
+ background: #ecfdf5;
2612
+ color: #059669;
2613
+ }
2614
+ .icon-ITERATION {
2615
+ background: #fce7f3;
2616
+ color: #db2777;
2617
+ }
2618
+ .icon-VARIABLE_AGGREGATOR {
2619
+ background: #e0f2fe;
2620
+ color: #0891b2;
2621
+ }
2622
+ .icon-VARIABLE_ASSIGNER {
2623
+ background: #d1fae5;
2624
+ color: #059669;
2625
+ }
2626
+ .icon-PARAMETER_EXTRACTOR {
2627
+ background: #fef3c7;
2628
+ color: #d97706;
2629
+ }
2630
+ .icon-LIST_OPERATOR {
2631
+ background: #cffafe;
2632
+ color: #0891b2;
2633
+ }
2634
+ .icon-DOCUMENT_EXTRACTOR {
2635
+ background: #cffafe;
2636
+ color: #0891b2;
2637
+ }
2638
+ .icon-HUMAN_INPUT {
2639
+ background: #fef3c7;
2640
+ color: #f59e0b;
2641
+ }
2642
+ .icon-HTTP_REQUEST {
2643
+ background: #e0f2fe;
2644
+ color: #0891b2;
2645
+ }
2646
+ .icon-SERVICE_API {
2647
+ background: #ccfbf1;
2648
+ color: #0d9488;
2649
+ }
2650
+ .icon-CODE {
2651
+ background: #f0fdf4;
2652
+ color: #16a34a;
2653
+ }
2654
+ .icon-TEMPLATE_TRANSFORM {
2655
+ background: #fef2f2;
2656
+ color: #dc2626;
2657
+ }
2658
+ .icon-ANSWER {
2659
+ background: #fff7ed;
2660
+ color: #ea580c;
2661
+ }
2662
+ .icon-END {
2663
+ background: #fef2f2;
2664
+ color: #dc2626;
2665
+ }
2666
+
2667
+ /* 滚动条样式 */
2668
+ .wf-selector-content::-webkit-scrollbar {
2669
+ width: 6px;
2670
+ }
2671
+
2672
+ .wf-selector-content::-webkit-scrollbar-track {
2673
+ background: transparent;
2674
+ }
2675
+
2676
+ .wf-selector-content::-webkit-scrollbar-thumb {
2677
+ background: #d1d5db;
2678
+ border-radius: 3px;
2679
+ }
2680
+
2681
+ .wf-selector-content::-webkit-scrollbar-thumb:hover {
2682
+ background: #9ca3af;
2683
+ }
2684
+
2685
+ /* 保留旧滚动条选择器以向后兼容(如仍有历史遗留) */
2686
+ .selector-content::-webkit-scrollbar {
2687
+ width: 6px;
2688
+ }
2689
+
2690
+ .selector-content::-webkit-scrollbar-track {
2691
+ background: #f1f5f9;
2692
+ border-radius: 3px;
2693
+ }
2694
+
2695
+ .selector-content::-webkit-scrollbar-thumb {
2696
+ background: #cbd5e1;
2697
+ border-radius: 3px;
2698
+ }
2699
+
2700
+ .selector-content::-webkit-scrollbar-thumb:hover {
2701
+ background: #94a3b8;
2702
+ }
2703
+
2704
+ /* 右侧预览卡片样式 */
2705
+ .node-preview-card {
2706
+ position: absolute;
2707
+ left: 240px;
2708
+ width: 220px;
2709
+ background: white;
2710
+ border: 1px solid #e5e7eb;
2711
+ border-radius: 8px;
2712
+ padding: 16px;
2713
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
2714
+ z-index: 10001;
2715
+ pointer-events: none;
2716
+ }
2717
+
2718
+ /* 右下角缩放/操作控件 */
2719
+ .wf-zoom-cluster {
2720
+ position: absolute;
2721
+ right: 12px;
2722
+ bottom: 130px; /* 让出小地图空间 */
2723
+ z-index: 8;
2724
+ display: flex;
2725
+ flex-direction: column;
2726
+ gap: 4px;
2727
+ padding: 4px;
2728
+ background: rgba(255, 255, 255, 0.96);
2729
+ border: 1px solid #e5e7eb;
2730
+ border-radius: 10px;
2731
+ box-shadow: 0 4px 16px rgba(15, 23, 42, 0.08);
2732
+ backdrop-filter: blur(6px);
2733
+ }
2734
+
2735
+ .wf-zoom-btn {
2736
+ display: flex;
2737
+ align-items: center;
2738
+ justify-content: center;
2739
+ width: 28px;
2740
+ height: 28px;
2741
+ color: #4b5563;
2742
+ font-size: 14px;
2743
+ line-height: 1;
2744
+ background: transparent;
2745
+ border: none;
2746
+ border-radius: 6px;
2747
+ cursor: pointer;
2748
+ transition: background 0.15s, color 0.15s;
2749
+ }
2750
+
2751
+ .wf-zoom-btn:hover {
2752
+ background: #f3f4f6;
2753
+ color: #1f2937;
2754
+ }
2755
+
2756
+ .wf-zoom-btn :deep(.icon) {
2757
+ width: 14px;
2758
+ height: 14px;
2759
+ }
2760
+
2761
+ /* 小地图容器 */
2762
+ :deep(.vue-flow__minimap.wf-minimap) {
2763
+ right: 12px !important;
2764
+ bottom: 12px !important;
2765
+ width: 160px !important;
2766
+ height: 100px !important;
2767
+ border: 1px solid #e5e7eb;
2768
+ border-radius: 10px;
2769
+ background: #ffffff !important;
2770
+ box-shadow: 0 4px 16px rgba(15, 23, 42, 0.08);
2771
+ overflow: hidden;
2772
+ }
2773
+
2774
+ .preview-header {
2775
+ display: flex;
2776
+ align-items: center;
2777
+ gap: 10px;
2778
+ margin-bottom: 12px;
2779
+ }
2780
+
2781
+ .preview-icon {
2782
+ display: flex;
2783
+ align-items: center;
2784
+ justify-content: center;
2785
+ width: 32px;
2786
+ height: 32px;
2787
+ border-radius: 6px;
2788
+ flex-shrink: 0;
2789
+ }
2790
+
2791
+ .preview-title {
2792
+ font-size: 16px;
2793
+ font-weight: 600;
2794
+ color: #1f2937;
2795
+ }
2796
+
2797
+ .preview-description {
2798
+ font-size: 13px;
2799
+ color: #6b7280;
2800
+ line-height: 1.5;
2801
+ }
2802
+ </style>