Skip to content

Commit 0516d43

Browse files
committed
feat(flow): add shared flow edge,node,guide components & hook
1 parent 104fadd commit 0516d43

File tree

20 files changed

+1036
-0
lines changed

20 files changed

+1036
-0
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<template>
2+
<div class="custom-height-resizer" @mousedown="handleMouseDown">
3+
<slot name="icon"></slot>
4+
</div>
5+
</template>
6+
7+
<script setup lang="ts">
8+
import { onUnmounted } from 'vue'
9+
10+
const props = withDefaults(
11+
defineProps<{
12+
modelValue: number
13+
min?: number
14+
max?: number
15+
/**
16+
* Whether the resizer is forward or not
17+
*/
18+
isForward?: boolean
19+
}>(),
20+
{
21+
min: 40,
22+
},
23+
)
24+
const emit = defineEmits<{
25+
(e: 'update:modelValue', value: number): void
26+
(e: 'resize', value: number): void
27+
}>()
28+
29+
let lastYPosition = 0
30+
31+
function setWidth(width: number) {
32+
emit('update:modelValue', width)
33+
}
34+
35+
const resize = (event: MouseEvent) => {
36+
const max = props.max || Number.MAX_SAFE_INTEGER
37+
const offset = Math.floor(event.pageY) - lastYPosition
38+
let targetHeight = props.modelValue + (props.isForward ? offset : -offset)
39+
if (targetHeight <= props.min) {
40+
targetHeight = props.min
41+
} else if (targetHeight >= max) {
42+
targetHeight = max
43+
}
44+
setWidth(targetHeight)
45+
lastYPosition = Math.floor(event.pageY)
46+
}
47+
48+
const handleMouseUp = () => {
49+
window.removeEventListener('mousemove', resize)
50+
window.removeEventListener('mouseup', handleMouseUp)
51+
emit('resize', props.modelValue)
52+
}
53+
54+
const handleMouseDown = (event: MouseEvent) => {
55+
lastYPosition = Math.floor(event.pageY)
56+
window.addEventListener('mousemove', resize)
57+
window.addEventListener('mouseup', handleMouseUp)
58+
event.preventDefault()
59+
}
60+
61+
onUnmounted(() => {
62+
window.removeEventListener('mousemove', resize)
63+
})
64+
</script>
65+
66+
<style lang="scss">
67+
.custom-height-resizer {
68+
display: flex;
69+
align-items: center;
70+
justify-content: center;
71+
width: 100%;
72+
height: 8px;
73+
z-index: 1;
74+
padding-left: 16px;
75+
padding-right: 16px;
76+
cursor: row-resize;
77+
}
78+
</style>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ConnectionStatus } from '../types'
2+
import { useFlowLocale } from './useFlowLocale'
3+
4+
export default (): {
5+
statusOptList: Array<{ value: ConnectionStatus; label: string }>
6+
statusLabelMap: Partial<Record<ConnectionStatus, string>>
7+
getActionStatusLabel: (status?: ConnectionStatus) => string
8+
} => {
9+
const { t } = useFlowLocale()
10+
const statusLabelMap: Partial<Record<ConnectionStatus, string>> = {
11+
[ConnectionStatus.Connected]: t('flow.actionAvailable'),
12+
[ConnectionStatus.Disconnected]: t('flow.actionUnavailable'),
13+
[ConnectionStatus.Connecting]: t('flow.connecting'),
14+
[ConnectionStatus.Inconsistent]: t('flow.inconsistent'),
15+
}
16+
const statusOptList = (Object.entries(statusLabelMap) as [ConnectionStatus, string][]).map(
17+
([key, value]) => ({
18+
value: key,
19+
label: value,
20+
}),
21+
)
22+
const getActionStatusLabel = (status?: ConnectionStatus) => {
23+
return status ? statusLabelMap[status] || t('flow.disconnected') : ''
24+
}
25+
26+
return {
27+
statusLabelMap,
28+
statusOptList,
29+
getActionStatusLabel,
30+
}
31+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Edge, Node } from '@vue-flow/core'
2+
import { createRandomString } from '@emqx/shared-ui-utils'
3+
import { NodeType } from '../types'
4+
import { useFlowLocale } from './useFlowLocale'
5+
6+
export const fallbackEdgeStyle = { stroke: '#bbb', strokeDasharray: '5 5' }
7+
8+
export default (): {
9+
allGuideFlowData: Array<Node | Edge>
10+
idFallback: string
11+
idFallbackEdge: string
12+
} => {
13+
const { t } = useFlowLocale()
14+
const idSource = createRandomString()
15+
const idProcessing = createRandomString()
16+
const idSink = createRandomString()
17+
const idFallback = createRandomString()
18+
const idFallbackEdge = createRandomString()
19+
const allGuideFlowData: Array<Node | Edge> = [
20+
{
21+
id: idSource,
22+
label: t('flow.guideSourceNodeLabel'),
23+
data: { type: NodeType.Source, desc: t('flow.guideSourceNodeDesc') },
24+
type: 'guide',
25+
position: { x: 90, y: 90 },
26+
},
27+
{
28+
id: idProcessing,
29+
label: t('flow.guideProcessingNodeLabel'),
30+
data: { type: NodeType.Processing, desc: t('flow.guideProcessingNodeDesc') },
31+
type: 'guide',
32+
position: { x: 290, y: 90 },
33+
},
34+
{
35+
id: idSink,
36+
label: t('flow.guideSinkNodeLabel'),
37+
data: { type: NodeType.Sink, desc: t('flow.guideSinkNodeDesc') },
38+
type: 'guide',
39+
position: { x: 490, y: 90 },
40+
},
41+
{
42+
id: idFallback,
43+
label: t('flow.guideFallbackNodeLabel'),
44+
data: { type: NodeType.Fallback, desc: t('flow.guideFallbackNodeDesc') },
45+
type: 'guide',
46+
position: { x: 690, y: 90 },
47+
},
48+
{
49+
id: createRandomString(),
50+
source: idSource,
51+
target: idProcessing,
52+
},
53+
{
54+
id: createRandomString(),
55+
source: idProcessing,
56+
target: idSink,
57+
},
58+
{
59+
id: idFallbackEdge,
60+
source: idSink,
61+
target: idFallback,
62+
style: fallbackEdgeStyle,
63+
},
64+
]
65+
66+
return {
67+
allGuideFlowData,
68+
idFallback,
69+
idFallbackEdge,
70+
}
71+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { Position, type Node } from '@vue-flow/core'
2+
import {
3+
BridgeType,
4+
FilterFormData,
5+
FilterFormType,
6+
FlowNodeType,
7+
FrontendSinkType,
8+
FrontendSourceType,
9+
NodeItem,
10+
NodeType,
11+
PositionData,
12+
ProcessingType,
13+
} from '../types'
14+
import { useFlowLocale } from './useFlowLocale'
15+
16+
export const isNotBridgeSourceTypes = [FrontendSourceType.Event, FrontendSourceType.Message]
17+
18+
/**
19+
* Cannot be added, only for show webhook
20+
*/
21+
export const SourceTypeAllMsgsAndEvents = 'all-msgs-and-events'
22+
23+
/**
24+
* Because the exact type of the ai node needs to be known after the details are fetched,
25+
* in order to treat the data as an ai node when processing it, assign a placeholder to it first.
26+
*/
27+
export const AI_PLACEHOLDER_TYPE = 'ai-placeholder'
28+
29+
export default (): {
30+
nodeWidth: number
31+
processingNodeList: Array<NodeItem>
32+
getNodeClass: (type: NodeType) => string
33+
getFlowNodeHookPosition: (nodeType: FlowNodeType) => PositionData
34+
getTypeCommonData: (type: NodeType) => { type: FlowNodeType; class: string } & PositionData
35+
isBridgerNode: (node: Partial<Node>) => boolean
36+
isActionBridgeNode: (node: Partial<Node>) => boolean
37+
isWithFallbackNodes: (node: Node) => boolean
38+
isBridgeType: (type: string) => boolean
39+
isAIType: (type: string) => boolean
40+
isLikeFunctionType: (type: string) => boolean
41+
getCommonTypeLabel: (specificType: string) => string
42+
getNodeInfoFunc: (node: Node, getEventLabel: (event: string) => string) => string
43+
} => {
44+
const { t } = useFlowLocale()
45+
46+
/**
47+
* just record, not for setting
48+
*/
49+
const nodeWidth = 200
50+
51+
const nodeClassMap: Record<NodeType, string> = {
52+
[NodeType.Source]: 'node-source',
53+
[NodeType.Processing]: 'node-processing',
54+
[NodeType.Sink]: 'node-sink',
55+
[NodeType.Fallback]: 'node-sink',
56+
}
57+
const getNodeClass = (type: NodeType) => nodeClassMap[type]
58+
59+
const getFlowNodeHookPosition = (nodeType: FlowNodeType) => {
60+
if (nodeType === FlowNodeType.Input) {
61+
return { sourcePosition: Position.Right }
62+
}
63+
if (nodeType === FlowNodeType.Output) {
64+
return { targetPosition: Position.Left }
65+
}
66+
return { sourcePosition: Position.Right, targetPosition: Position.Left }
67+
}
68+
69+
const typeMap = {
70+
[NodeType.Source]: FlowNodeType.Input,
71+
[NodeType.Processing]: FlowNodeType.Default,
72+
[NodeType.Sink]: FlowNodeType.Output,
73+
[NodeType.Fallback]: FlowNodeType.Output,
74+
}
75+
const getTypeCommonData = (type: NodeType) => {
76+
const flowNodeType = typeMap[type]
77+
return {
78+
class: `node-item ${getNodeClass(type)}`,
79+
type: flowNodeType,
80+
...getFlowNodeHookPosition(flowNodeType),
81+
}
82+
}
83+
84+
const commonTypeLabelMap: Record<string, string> = {
85+
[FrontendSourceType.Message]: t('flow.message'),
86+
[FrontendSourceType.Event]: t('flow.event'),
87+
[ProcessingType.Function]: t('flow.dataProcessing'),
88+
[ProcessingType.Filter]: t('flow.filter'),
89+
[ProcessingType.AIOpenAI]: 'OpenAI',
90+
[ProcessingType.AIAnthropic]: 'Anthropic',
91+
[ProcessingType.AIGemini]: 'Gemini',
92+
[FrontendSinkType.Console]: t('flow.consoleOutput'),
93+
[FrontendSinkType.Republish]: t('flow.republish'),
94+
}
95+
const getCommonTypeLabel = (specificType: string): string => {
96+
return commonTypeLabelMap[specificType] || ''
97+
}
98+
99+
const countFiltersNum = (filter: FilterFormData) => {
100+
return filter.items.reduce((count, item) => {
101+
if ('items' in item) {
102+
count += countFiltersNum(item)
103+
} else {
104+
count += 1
105+
}
106+
return count
107+
}, 0)
108+
}
109+
110+
const isNotBridgeSourceNodeTypes = [
111+
FrontendSourceType.Message,
112+
FrontendSourceType.Event,
113+
SourceTypeAllMsgsAndEvents,
114+
]
115+
const isNotBridgeSinkNodeTypes = [FrontendSinkType.Console, FrontendSinkType.Republish]
116+
/**
117+
* ‼️‼️‼️ bridge node contains source and action node
118+
*/
119+
const isBridgerNode = ({ type, data }: Partial<Node>): boolean => {
120+
const { specificType } = data || {}
121+
return (
122+
(type === FlowNodeType.Input &&
123+
!isNotBridgeSourceNodeTypes.includes(specificType as string)) ||
124+
(type === FlowNodeType.Output && !isNotBridgeSinkNodeTypes.includes(specificType))
125+
)
126+
}
127+
const isActionBridgeNode = (node: Partial<Node>): boolean =>
128+
node.type === FlowNodeType.Output && isBridgerNode(node)
129+
130+
const isWithFallbackNodes = (node?: Node) => {
131+
if (!node || node?.type !== FlowNodeType.Output || !isBridgerNode(node)) {
132+
return false
133+
}
134+
const fallbackActions = node.data.formData?.fallback_actions ?? []
135+
return fallbackActions.length > 0
136+
}
137+
138+
const isNotBridgeTypes: Array<string> = [
139+
...isNotBridgeSourceTypes,
140+
...Object.values(ProcessingType),
141+
FrontendSinkType.Republish,
142+
FrontendSinkType.Console,
143+
]
144+
const isBridgeType = (type: string) => {
145+
const isBridge = Object.entries(BridgeType).some(([, value]) => value === type)
146+
return !isNotBridgeTypes.includes(type) && isBridge
147+
}
148+
149+
const defaultTypesNotAI = [ProcessingType.Filter, ProcessingType.Function]
150+
const isAIType = (type: string) => {
151+
return (
152+
type === AI_PLACEHOLDER_TYPE ||
153+
(Object.values(ProcessingType).includes(type as ProcessingType) &&
154+
!defaultTypesNotAI.includes(type as ProcessingType))
155+
)
156+
}
157+
158+
const isLikeFunctionType = (type: string) => {
159+
return isAIType(type) || type === ProcessingType.Function
160+
}
161+
162+
const getFilterInfo = (filter: FilterFormType) => {
163+
const num = countFiltersNum(filter.form)
164+
return `${num} ${t('flow.condition', num)}`
165+
}
166+
167+
const getNodeInfoFunc = (node: Node, getEventLabel: (event: string) => string): string => {
168+
const { specificType, formData } = node.data
169+
if (!specificType || !formData) {
170+
return ''
171+
}
172+
if (isAIType(specificType)) {
173+
return `${t('flow.systemPrompt')}${t('common.colon')}${formData.system_prompt}`
174+
}
175+
switch (specificType) {
176+
case FrontendSourceType.Message:
177+
return `${t('common.topic')}${t('common.colon')}${formData.topic}`
178+
case FrontendSourceType.Event:
179+
return `${t('flow.event')}${t('common.colon')}${getEventLabel(formData.event)}`
180+
case ProcessingType.Function:
181+
return ''
182+
case ProcessingType.Filter:
183+
return getFilterInfo(formData)
184+
case FrontendSinkType.Console:
185+
return ''
186+
case FrontendSinkType.Republish:
187+
return `${t('common.topic')}${t('common.colon')}${formData.args?.topic}`
188+
default:
189+
return `${t('common.name')}${t('common.colon')}${formData.name}`
190+
}
191+
}
192+
193+
const generateProcessingNodeByType = (type: string): NodeItem => ({
194+
name: getCommonTypeLabel(type),
195+
specificType: type,
196+
})
197+
const processingNodeList: Array<NodeItem> = Object.values(ProcessingType).map(
198+
generateProcessingNodeByType,
199+
)
200+
201+
return {
202+
nodeWidth,
203+
processingNodeList,
204+
getNodeClass,
205+
getFlowNodeHookPosition,
206+
getTypeCommonData,
207+
isBridgerNode,
208+
isActionBridgeNode,
209+
isWithFallbackNodes,
210+
isBridgeType,
211+
isAIType,
212+
isLikeFunctionType,
213+
getCommonTypeLabel,
214+
getNodeInfoFunc,
215+
}
216+
}

0 commit comments

Comments
 (0)