Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 74 additions & 8 deletions ui/src/workflow-canvas/component/NodeMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { WorkflowNodeType } from '@/workflow-canvas/types'

defineOptions({ name: 'NodeMenu' })

const emit = defineEmits<{ select: [nodeType: WorkflowNodeType] }>()
const emit = defineEmits<{
select: [nodeType: WorkflowNodeType]
}>()

type WorkflowComponentTab = 'basic' | 'tool' | 'application'

Expand All @@ -21,22 +23,80 @@ const workflowComponentTabs: Array<{ label: string; value: WorkflowComponentTab
]

const workflowComponentOptions = [
{ icon: aiChatIcon, iconClass: 'bg-primary-gradient', label: 'AI 对话', value: WorkflowNodeType.AiChat },
{ icon: replyIcon, iconClass: 'bg-warning', label: '指定回复', value: WorkflowNodeType.Reply },
] satisfies Array<{ icon: string; iconClass: string; label: string; value: WorkflowNodeType }>
{
icon: aiChatIcon,
iconClass: 'bg-primary-gradient',
label: 'AI 对话',
value: WorkflowNodeType.AiChat,
},
{
icon: aiChatIcon,
iconClass: 'bg-primary-gradient',
label: '意图识别',
value: WorkflowNodeType.IntentNode,
},
// {
// icon: aiChatIcon,
// iconClass: 'bg-primary-gradient',
// label: '文本转语音',
// value: WorkflowNodeType.TextToSpeechNode,
// },
// {
// icon: aiChatIcon,
// iconClass: 'bg-primary-gradient',
// label: '语音转文本',
// value: WorkflowNodeType.SpeechToTextNode,
// },
// {
// icon: aiChatIcon,
// iconClass: 'bg-primary-gradient',
// label: '图片生成',
// value: WorkflowNodeType.ImageGenerateNode,
// },
// {
// icon: aiChatIcon,
// iconClass: 'bg-primary-gradient',
// label: '图片理解',
// value: WorkflowNodeType.ImageUnderstandNode,
// },
// {
// icon: aiChatIcon,
// iconClass: 'bg-primary-gradient',
// label: '问题优化',
// value: WorkflowNodeType.Question,
// },
{
icon: replyIcon,
iconClass: 'bg-warning',
label: '指定回复',
value: WorkflowNodeType.Reply,
},
] satisfies Array<{
icon: string
iconClass: string
label: string
value: WorkflowNodeType
}>

const filteredComponentOptions = computed(() => {
if (activeTab.value !== 'basic') return []

const keyword = searchKeyword.value.trim().toLocaleLowerCase()
if (!keyword) return workflowComponentOptions

return workflowComponentOptions.filter((componentOption) => componentOption.label.toLocaleLowerCase().includes(keyword))
return workflowComponentOptions.filter((componentOption) =>
componentOption.label.toLocaleLowerCase().includes(keyword),
)
})
</script>

<template>
<div class="w-[402px] overflow-hidden rounded-md border border-N300 bg-white shadow-lg" @click.stop @mousedown.stop @mousemove.stop>
<div
class="w-[402px] overflow-hidden rounded-md border border-N300 bg-white shadow-lg"
@click.stop
@mousedown.stop
@mousemove.stop
>
<div class="flex h-11 items-stretch gap-7 border-b border-N300 px-4" role="tablist">
<button
v-for="componentTab in workflowComponentTabs"
Expand All @@ -49,7 +109,10 @@ const filteredComponentOptions = computed(() => {
@click="activeTab = componentTab.value"
>
{{ componentTab.label }}
<span v-if="activeTab === componentTab.value" class="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-primary" />
<span
v-if="activeTab === componentTab.value"
class="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-primary"
/>
</button>
</div>

Expand All @@ -66,7 +129,10 @@ const filteredComponentOptions = computed(() => {
class="flex h-10 items-center gap-3 rounded-md border border-N300 px-3 text-left text-N900 hover:border-primary hover:text-primary"
@click="emit('select', componentOption.value)"
>
<span class="flex size-6 shrink-0 items-center justify-center rounded-md" :class="componentOption.iconClass">
<span
class="flex size-6 shrink-0 items-center justify-center rounded-md"
:class="componentOption.iconClass"
>
<img :src="componentOption.icon" alt="" class="size-4" />
</span>
<span>{{ componentOption.label }}</span>
Expand Down
6 changes: 6 additions & 0 deletions ui/src/workflow-canvas/icons/intent-node-icon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<el-avatar class="avatar-gradient" shape="square">
<img src="@/assets/workflow/icon_ai_chat.svg" style="width: 75%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
125 changes: 125 additions & 0 deletions ui/src/workflow-canvas/nodes/intent-node/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import IntentNodeViewVue from './index.vue'
import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node'
import { WorkflowNodeType } from '@/workflow-canvas/types'
import { randomId } from '@/utils/common'
import type { Model } from '@logicflow/core'

interface IntentNodeBranch {
id: string
content: string
isOther: boolean
}

interface IntentNodeData {
model_id: string
model_id_type: 'custom' | 'reference'
model_id_reference: string[]
model_params_setting: Record<string, unknown>
content_list: string[]
dialogue_type: 'NODE' | 'WORKFLOW'
dialogue_number: number
branch: IntentNodeBranch[]
}

/** 节点顶部到第一个分类行锚点的纵向偏移(近似)。 */
const BRANCH_ANCHOR_TOP_OFFSET = 422
/** 相邻分类行锚点的纵向间距(近似行高)。 */
const BRANCH_ANCHOR_GAP = 40

type UpdatePathEdge = { updatePathByAnchor: () => void }

function defaultBranch(): IntentNodeBranch[] {
return [
{ id: randomId(), content: '', isOther: false },
{ id: randomId(), content: '其他', isOther: true },
]
}

function defaultNodeData(): IntentNodeData {
return {
model_id: '',
model_id_type: 'custom',
model_id_reference: [],
model_params_setting: {},
content_list: [],
dialogue_type: 'WORKFLOW',
dialogue_number: 1,
branch: defaultBranch(),
}
}

class IntentNodeModel extends WorkflowNodeModel {
setAttributes() {
super.setAttributes()
// 预置 node_data 为一个可用的默认分支,保证 Vue 表单挂载前锚点已存在
const nodeData = this.properties.node_data as IntentNodeData | undefined
if (!nodeData) {
this.properties.node_data = defaultNodeData()
} else if (!Array.isArray(nodeData.branch) || nodeData.branch.length === 0) {
nodeData.branch = defaultBranch()
}
}

refreshBranch() {
this.incoming.edges.forEach((edge) => (edge as unknown as UpdatePathEdge).updatePathByAnchor())
this.outgoing.edges.forEach((edge) => (edge as unknown as UpdatePathEdge).updatePathByAnchor())
}

getDefaultAnchor(): Model.AnchorConfig[] {
const { id, x, y, width, height } = this
const anchors: Model.AnchorConfig[] = []

anchors.push({
x: x - width / 2,
y,
id: `${id}_left`,
type: 'left',
edgeAddable: false,
})

const showNode = this.properties.showNode ?? true
const nodeData = this.properties.node_data as IntentNodeData | undefined
const branchList = Array.isArray(nodeData?.branch) ? nodeData.branch : []

if (!showNode) {
// 收起后所有分支锚点归并为“其他”一个右锚点,节点只显示一个右锚点。
const mergeTarget =
branchList.find((branch) => branch.isOther) ?? branchList[branchList.length - 1]
if (mergeTarget) {
anchors.push({
x: x + width / 2,
y,
id: `${id}_${mergeTarget.id}_right`,
type: 'right',
})
}
return anchors
}

// 展开时每个分支一个右锚点,锚点从节点顶部偏移近似对齐到各分类行,
// 锚点 id 由后端 branch_anchor(branch_id) 约定为 `${id}_${branch_id}_right`。
const startY = y - height / 2 + BRANCH_ANCHOR_TOP_OFFSET
branchList.forEach((branch, index) => {
anchors.push({
x: x + width / 2,
y: startY + index * BRANCH_ANCHOR_GAP,
id: `${id}_${branch.id}_right`,
type: 'right',
})
})

return anchors
}
}

class IntentNodeView extends WorkflowNodeView {
constructor(props: ConstructorParameters<typeof WorkflowNodeView>[0]) {
super(props, IntentNodeViewVue)
}
}

export default {
type: WorkflowNodeType.IntentNode,
model: IntentNodeModel,
view: IntentNodeView,
}
Loading
Loading