feat: 工作编排功能

This commit is contained in:
wangdan-fit2cloud 2024-05-28 15:54:49 +08:00
parent c77b20c079
commit 5a29e83879
12 changed files with 843 additions and 0 deletions

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "MaxKB",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -14,6 +14,7 @@
},
"dependencies": {
"@ctrl/tinycolor": "^4.1.0",
"@logicflow/core": "^1.2.27",
"@vueuse/core": "^10.9.0",
"axios": "^0.28.0",
"cropperjs": "^1.6.2",

View File

@ -0,0 +1,165 @@
import Components from '@/components'
import ElementPlus from 'element-plus'
import { HtmlNode, HtmlNodeModel } from '@logicflow/core'
import { createApp, h } from 'vue'
import directives from '@/directives'
class AppNode extends HtmlNode {
isMounted
r
app
constructor(props: any, VueNode: any) {
super(props)
this.isMounted = false
this.r = h(VueNode, {
properties: props.model.properties,
nodeModel: props.model
})
this.app = createApp({
render: () => this.r
})
this.app.use(ElementPlus)
this.app.use(Components)
this.app.use(directives)
}
setHtml(rootEl: HTMLElement) {
if (!this.isMounted) {
this.isMounted = true
const node = document.createElement('div')
rootEl.appendChild(node)
this.app.mount(node)
} else {
if (this.r && this.r.component) {
this.r.component.props.properties = this.props.model.getProperties()
}
}
}
}
class AppNodeModel extends HtmlNodeModel {
/**
* model自定义添加字段方法
*/
addField(item: any) {
this.properties.fields.unshift(item)
this.setAttributes()
// 为了保持节点顶部位置不变,在节点变化后,对节点进行一个位移,位移距离为添加高度的一半。
this.move(0, 24 / 2)
// 更新节点连接边的path
this.incoming.edges.forEach((egde: any) => {
// 调用自定义的更新方案
egde.updatePathByAnchor()
})
this.outgoing.edges.forEach((edge: any) => {
// 调用自定义的更新方案
edge.updatePathByAnchor()
})
}
getOutlineStyle() {
const style = super.getOutlineStyle()
style.stroke = 'none'
if (style.hover) {
style.hover.stroke = 'none'
}
return style
}
// 如果不用修改锚地形状,可以重写颜色相关样式
getAnchorStyle(anchorInfo: any) {
const style = super.getAnchorStyle(anchorInfo)
if (anchorInfo.type === 'left') {
style.fill = 'red'
style.hover.fill = 'transparent'
style.hover.stroke = 'transpanrent'
style.className = 'lf-hide-default'
} else {
style.fill = 'green'
}
return style
}
setHeight(height: number, inputContainerHeight: number, outputContainerHeight: number) {
this.height = height + inputContainerHeight + outputContainerHeight + 100
this.baseHeight = height
this.inputContainerHeight = inputContainerHeight
this.outputContainerHeight = outputContainerHeight
this.outgoing.edges.forEach((edge: any) => {
// 调用自定义的更新方案
edge.updatePathByAnchor()
})
this.incoming.edges.forEach((edge: any) => {
// 调用自定义的更新方案
edge.updatePathByAnchor()
})
}
setAttributes() {
this.width = 500
const circleOnlyAsTarget = {
message: '只允许从右边的锚点连出',
validate: (sourceNode: any, targetNode: any, sourceAnchor: any) => {
return sourceAnchor.type === 'right'
}
}
this.sourceRules.push(circleOnlyAsTarget)
this.targetRules.push({
message: '只允许连接左边的锚点',
validate: (sourceNode: any, targetNode: any, sourceAnchor: any, targetAnchor: any) => {
return targetAnchor.type === 'left'
}
})
}
getDefaultAnchor() {
const {
id,
x,
y,
width,
height,
properties: { input, output }
} = this
if (this.baseHeight === undefined) {
this.baseHeight = 0
}
if (this.inputContainerHeight === undefined) {
this.inputContainerHeight = 0
}
if (this.height === undefined) {
this.height = 200
}
if (this.outputContainerHeight === undefined) {
this.outputContainerHeight = 0
}
const anchors: any = []
if (input) {
input.forEach((feild: any, index: any) => {
anchors.push({
x: x - width / 2 + 10,
y: y - height / 2 + this.baseHeight + 35 + index * 24,
id: `${id}_${feild.key}_left`,
edgeAddable: false,
type: 'left'
})
})
}
if (output) {
output.forEach((feild: any, index: any) => {
anchors.push({
x: x + width / 2 - 10,
y: y - height / 2 + this.baseHeight + this.inputContainerHeight + 30 + index * 24,
id: `${id}_${feild.key}_right`,
type: 'right'
})
})
}
return anchors
}
}
export { AppNodeModel, AppNode }

View File

@ -0,0 +1,62 @@
import { BezierEdge, BezierEdgeModel } from '@logicflow/core'
class CustomEdge2 extends BezierEdge {}
class CustomEdgeModel2 extends BezierEdgeModel {
getEdgeStyle() {
const style = super.getEdgeStyle()
// svg属性
style.strokeWidth = 1
style.stroke = '#ababac'
return style
}
/**
* 使
*/
getData() {
const data: any = super.getData()
if (data) {
data.sourceAnchorId = this.sourceAnchorId
data.targetAnchorId = this.targetAnchorId
}
return data
}
/**
* 使
*/
updatePathByAnchor() {
// TODO
const sourceNodeModel = this.graphModel.getNodeModelById(this.sourceNodeId)
const sourceAnchor = sourceNodeModel
.getDefaultAnchor()
.find((anchor: any) => anchor.id === this.sourceAnchorId)
const targetNodeModel = this.graphModel.getNodeModelById(this.targetNodeId)
const targetAnchor = targetNodeModel
.getDefaultAnchor()
.find((anchor: any) => anchor.id === this.targetAnchorId)
if (sourceAnchor && targetAnchor) {
const startPoint = {
x: sourceAnchor.x,
y: sourceAnchor.y
}
this.updateStartPoint(startPoint)
const endPoint = {
x: targetAnchor.x,
y: targetAnchor.y
}
this.updateEndPoint(endPoint)
}
// 这里需要将原有的pointsList设置为空才能触发bezier的自动计算control点。
this.pointsList = []
this.initPoints()
}
}
export default {
type: 'app-edge',
view: CustomEdge2,
model: CustomEdgeModel2
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,115 @@
import LogicFlow from '@logicflow/core'
import { shapeList } from './data'
type ShapeItem = {
type?: string
text?: string
icon?: string
label?: string
className?: string
disabled?: boolean
properties?: Record<string, any>
callback?: (lf: LogicFlow, container: HTMLElement) => void
}
class AppMenu {
lf: LogicFlow
shapeList: ShapeItem[]
panelEl?: HTMLDivElement
static pluginName = 'AppMenu'
domContainer?: HTMLElement
constructor({ lf }: { lf: LogicFlow }) {
this.lf = lf
this.lf.setPatternItems = (shapeList: Array<ShapeItem>) => {
this.setPatternItems(shapeList)
}
this.shapeList = shapeList
this.panelEl = undefined
this.domContainer = undefined
}
render(lf: LogicFlow, domContainer: HTMLElement) {
this.destroy()
if (!this.shapeList || this.shapeList.length === 0) {
// 首次render后失败后后续调用setPatternItems支持渲染
this.domContainer = domContainer
return
}
this.panelEl = document.createElement('div')
this.panelEl.className = 'lf-dndpanel'
this.shapeList.forEach((shapeItem) => {
this.panelEl?.appendChild(this.createDndItem(shapeItem))
})
domContainer.appendChild(this.panelEl)
this.domContainer = domContainer
}
destroy() {
if (this.domContainer && this.panelEl && this.domContainer.contains(this.panelEl)) {
this.domContainer.removeChild(this.panelEl)
}
}
setPatternItems(shapeList: Array<ShapeItem>) {
this.shapeList = shapeList
// 支持渲染后重新设置拖拽面板
if (this.domContainer) {
this.render(this.lf, this.domContainer)
}
}
private createDndItem(shapeItem: ShapeItem): HTMLElement {
const el = document.createElement('div')
el.className = shapeItem.className ? `lf-dnd-item ${shapeItem.className}` : 'lf-dnd-item'
const shape = document.createElement('div')
shape.className = 'lf-dnd-shape'
if (shapeItem.icon) {
shape.style.backgroundImage = `url(${shapeItem.icon})`
}
el.appendChild(shape)
if (shapeItem.label) {
const text = document.createElement('div')
text.innerText = shapeItem.label
text.className = 'lf-dnd-text'
el.appendChild(text)
}
if (shapeItem.disabled) {
el.classList.add('disabled')
// 保留callback的执行可用于界面提示当前shapeItem的禁用状态
el.onmousedown = () => {
if (shapeItem.callback && this.domContainer) {
shapeItem.callback(this.lf, this.domContainer)
}
}
return el
}
el.onmousedown = () => {
if (shapeItem.type) {
this.lf.dnd.startDrag({
type: shapeItem.type,
properties: shapeItem.properties
})
}
if (shapeItem.callback && this.domContainer) {
shapeItem.callback(this.lf, this.domContainer)
}
}
el.ondblclick = (e) => {
this.lf.graphModel.eventCenter.emit('dnd:panel-dbclick', {
e,
data: shapeItem
})
}
el.onclick = (e) => {
this.lf.graphModel.eventCenter.emit('dnd:panel-click', {
e,
data: shapeItem
})
}
el.oncontextmenu = (e) => {
this.lf.graphModel.eventCenter.emit('dnd:panel-contextmenu', {
e,
data: shapeItem
})
}
return el
}
}
export { AppMenu }

View File

@ -0,0 +1,170 @@
<template>
<div class="container">
<div class="step-container" :class="`step-color-${Math.ceil(Math.random() * 4)}`">
<div v-resize="resizeStepContainer">
<div class="step-name">{{ nodeModel.properties.stepName }}</div>
<div style="padding: 10px"><slot></slot></div>
</div>
<div class="input-container" v-resize="resetInputContainer">
<el-divider> </el-divider>
<div v-for="item in nodeModel.properties.input" class="step-feild">
<span>{{ item.key }}</span>
</div>
</div>
<div class="outout-container" v-resize="resetOutputContainer">
<el-divider> </el-divider>
<div v-for="item in nodeModel.properties.output" class="out-step-feild">
<span>{{ item.key }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const height = ref<{
stepContanerHeight: number
inputContainerHeight: number
outputContainerHeight: number
}>({
stepContanerHeight: 0,
inputContainerHeight: 0,
outputContainerHeight: 0
})
const resizeStepContainer = (wh: any) => {
if (wh.height) {
height.value.stepContanerHeight = wh.height
props.nodeModel.setHeight(
height.value.stepContanerHeight,
height.value.inputContainerHeight,
height.value.outputContainerHeight
)
}
}
const resetOutputContainer = (wh: { height: number; width: number }) => {
if (wh.height) {
height.value.outputContainerHeight = wh.height
props.nodeModel.setHeight(
height.value.stepContanerHeight,
height.value.inputContainerHeight,
height.value.outputContainerHeight
)
}
}
const resetInputContainer = (wh: { height: number; width: number }) => {
if (wh.height) {
height.value.inputContainerHeight = wh.height
props.nodeModel.setHeight(
height.value.stepContanerHeight,
height.value.inputContainerHeight,
height.value.outputContainerHeight
)
}
}
const props = defineProps<{
nodeModel: any
}>()
</script>
<style lang="scss">
.el-divider--horizontal {
margin: 0;
}
.container {
box-sizing: border-box;
padding: 10px;
}
.step-container {
width: 100%;
height: 100%;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.step-container::before {
display: block;
width: 100%;
height: 8px;
background: #d79b00;
content: '';
}
.step-container.step-color-1::before {
background: #9673a6;
}
.step-container.step-color-2::before {
background: #dae8fc;
}
.step-container.step-color-3::before {
background: #82b366;
}
.step-container.step-color-4::before {
background: #f8cecc;
}
.step-name {
height: 28px;
font-size: 14px;
line-height: 28px;
text-align: center;
background: #f5f5f5;
}
.step-feild {
display: flex;
justify-content: space-between;
height: 24px;
padding: 0 10px;
font-size: 12px;
line-height: 24px;
}
.feild-type {
color: #9f9c9f;
}
.out-step-feild {
display: flex;
justify-content: space-between;
height: 24px;
padding: 0 10px;
font-size: 12px;
line-height: 24px;
float: right;
}
/* 自定义锚点样式 */
.custom-anchor {
cursor: crosshair;
fill: #d9d9d9;
stroke: #999;
stroke-width: 1;
rx: 3;
ry: 3;
}
.custom-anchor:hover {
fill: #ff7f0e;
stroke: #ff7f0e;
}
.lf-node-not-allow .custom-anchor:hover {
cursor: not-allowed;
fill: #d9d9d9;
stroke: #999;
}
.incomming-anchor {
stroke: #d79b00;
}
.outgoing-anchor {
stroke: #82b366;
}
</style>

View File

@ -0,0 +1,153 @@
<template>
<button @click="validate">点击校验</button>
<button @click="getGraphData">点击获取流程数据</button>
<div className="helloworld-app sql" style="height: 100vh; width: 100vw" id="container"></div>
</template>
<script setup lang="ts">
import LogicFlow from '@logicflow/core'
import { ref, onMounted } from 'vue'
import AiChatNode from '@/work-flow/nodes/ai-chat-node/index.ts'
import AppEdge from '@/work-flow/common/edge/index'
import { AppMenu } from '@/work-flow/common/menu/index'
import '@logicflow/extension/lib/style/index.css'
import '@logicflow/core/dist/style/index.css'
LogicFlow.use(AppMenu)
const graphData = {
nodes: [
{
id: '92a94b25-453d-4a00-aa26-9fed9b487e08',
type: 'ai-chat-node',
x: -10,
y: 239,
properties: {
height: 200,
stepName: 'AI对话',
input: [{ key: '输入' }],
output: [{ key: '输出' }],
node_data: { model: 'shanghai', name: '222' }
}
},
{
id: '34902d3d-a3ff-497f-b8e1-0c34a44d7dd4',
type: 'ai-chat-node',
x: 143,
y: 523,
properties: {
height: 200,
stepName: 'AI对话',
input: [{ key: '输入' }],
output: [{ key: '输出' }],
node_data: { model: 'shanghai', name: '222222' }
}
}
],
edges: [
{
id: 'bc7297fa-2409-4c85-9a4d-3d74c9c1e30f',
type: 'app-edge',
sourceNodeId: '92a94b25-453d-4a00-aa26-9fed9b487e08',
targetNodeId: '34902d3d-a3ff-497f-b8e1-0c34a44d7dd4',
startPoint: { x: 230, y: 333.000005 },
endPoint: { x: -97, y: 596.111105 },
properties: {},
pointsList: [
{ x: 230, y: 333.000005 },
{ x: 340, y: 333.000005 },
{ x: -207, y: 596.111105 },
{ x: -97, y: 596.111105 }
],
sourceAnchorId: '92a94b25-453d-4a00-aa26-9fed9b487e08_输出_right',
targetAnchorId: '34902d3d-a3ff-497f-b8e1-0c34a44d7dd4_输入_left'
},
{
id: '9f5740ce-b55e-42d4-90a2-a06f34d6f5ef',
type: 'app-edge',
sourceNodeId: '92a94b25-453d-4a00-aa26-9fed9b487e08',
targetNodeId: '34902d3d-a3ff-497f-b8e1-0c34a44d7dd4',
startPoint: { x: 230, y: 333.000005 },
endPoint: { x: -97, y: 596.111105 },
properties: {},
pointsList: [
{ x: 230, y: 333.000005 },
{ x: 340, y: 333.000005 },
{ x: -207, y: 596.111105 },
{ x: -97, y: 596.111105 }
],
sourceAnchorId: '92a94b25-453d-4a00-aa26-9fed9b487e08_输出_right',
targetAnchorId: '34902d3d-a3ff-497f-b8e1-0c34a44d7dd4_输入_left'
}
]
}
const lf = ref()
onMounted(() => {
const container: any = document.querySelector('#container')
if (container) {
lf.value = new LogicFlow({
keyboard: {
enabled: true,
shortcuts: [
{
keys: ['backspace'],
callback: () => {
const elements = lf.value.getSelectElements(true)
if (
(elements.edges && elements.edges.length > 0) ||
(elements.nodes && elements.nodes.length > 0)
) {
const r = window.confirm('确定要删除吗?')
if (r) {
lf.value.clearSelectElements()
elements.edges.forEach((edge: any) => {
lf.value.deleteEdge(edge.id)
})
elements.nodes.forEach((node: any) => {
lf.value.deleteNode(node.id)
})
}
}
}
}
]
},
isSilentMode: false,
container: container
})
lf.value.setTheme({
bezier: {
stroke: '#afafaf',
strokeWidth: 1
}
})
lf.value.register(AiChatNode)
lf.value.register(AppEdge)
lf.value.setDefaultEdgeType('app-edge')
lf.value.render(graphData)
lf.value.translateCenter()
}
})
const validate = () => {
lf.value.graphModel.nodes.forEach((element: any) => {
element.validate()
})
}
const getGraphData = () => {
console.log(JSON.stringify(lf.value.getGraphData()))
}
</script>
<style lang="scss">
.lf-dnd-text {
width: 200px;
}
.lf-dnd-shape {
height: 50px;
}
.lf-node-selected {
border: 1px solid #000;
}
</style>

View File

@ -0,0 +1,12 @@
import ChatNodeVue from '@/flow/nodes/ai-chat-node/index.vue'
import { AppNode, AppNodeModel } from '@/flow/common/app-node/index'
class ChatNode extends AppNode {
constructor(props: any) {
super(props, ChatNodeVue)
}
}
export default {
type: 'ai-chat-node',
model: AppNodeModel,
view: ChatNode
}

View File

@ -0,0 +1,72 @@
<template>
<NodeContaner :nodeModel="nodeModel">
<el-form
@submit.prevent
:model="chat_data"
label-position="top"
require-asterisk-position="right"
class="mb-24"
label-width="auto"
ref="aiChatNodeFormRef"
>
<el-form-item
label="模型"
prop="model"
:rules="{
message: '模型不能为空',
trigger: 'blur',
required: true
}"
>
<el-select v-model="chat_data.model" placeholder="请选择模型">
<el-option label="Zone one" value="shanghai" />
<el-option label="Zone two" value="beijing" />
</el-select>
</el-form-item>
<el-form-item
label="提示词"
:rules="{
message: '提示词不能为空',
trigger: 'blur',
required: true
}"
prop="name"
>
<el-input v-model="chat_data.name" @focus="handleFocus" />
</el-form-item>
</el-form>
</NodeContaner>
</template>
<script setup lang="ts">
import NodeContaner from '@/flow/common/node-container/index.vue'
import type { FormInstance } from 'element-plus'
import { ref, computed, onMounted } from 'vue'
const chat_data = computed({
get: () => {
if (props.nodeModel.properties.node_data) {
return props.nodeModel.properties.node_data
} else {
props.nodeModel.properties.node_data = { model: '', name: '' }
}
return props.nodeModel.properties.node_data
},
set: (value) => {
props.nodeModel.properties.node_data = value
}
})
const props = defineProps<{ nodeModel: any }>()
const handleFocus = () => {
props.nodeModel.isSelected = false
}
const aiChatNodeFormRef = ref<FormInstance>()
const validate = () => {
aiChatNodeFormRef.value?.validate()
}
onMounted(() => {
props.nodeModel.validate = validate
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,12 @@
import SearchDatasetVue from './index.vue'
import { AppNode, AppNodeModel } from '@/flow/common/app-node/index'
class SearchDatasetNode extends AppNode {
constructor(props: any) {
super(props, SearchDatasetVue)
}
}
export default {
type: 'search-dataset-node',
model: AppNodeModel,
view: SearchDatasetNode
}

View File

@ -0,0 +1,51 @@
<template>
<NodeContaner :nodeModel="nodeModel">
<el-form
:model="chat_data"
label-position="top"
require-asterisk-position="right"
class="mb-24"
label-width="auto"
ref="aiChatNodeFormRef"
>
<el-form-item label="知识库">
<el-select v-model="chat_data.model" placeholder="请选择模型">
<el-option label="Zone one" value="shanghai" />
<el-option label="Zone two" value="beijing" />
</el-select>
</el-form-item>
<el-form-item label="提示词">
<el-input v-model="chat_data.name" />
</el-form-item>
</el-form>
</NodeContaner>
</template>
<script setup lang="ts">
import NodeContaner from '@/flow/common/node-container/index.vue'
import type { FormInstance } from 'element-plus'
import { ref, computed, onMounted } from 'vue'
const chat_data = computed({
get: () => {
if (props.nodeModel.properties.node_data) {
return props.nodeModel.properties.node_data
}
return {}
},
set: (value) => {
props.nodeModel.properties.node_data = value
}
})
const props = defineProps<{ nodeModel: any }>()
const aiChatNodeFormRef = ref<FormInstance>()
const validate = () => {
aiChatNodeFormRef.value?.validate()
}
onMounted(() => {
props.nodeModel.validate = validate
})
</script>
<style lang="scss" scoped></style>