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
4 changes: 4 additions & 0 deletions ui/src/assets/workflow/icon_tool_custom.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions ui/src/components/codemirror-editor/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,7 @@
background-color: var(--el-scrollbar-hover-bg-color);
opacity: var(--el-scrollbar-hover-opacity);
}
:deep(.cm-content) {
background: white;
}
}
5 changes: 5 additions & 0 deletions ui/src/workflow-canvas/WORKFLOW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ AI 对话、意图识别、问题优化、参数提取、图片理解、视频
中间 computed。独立弹窗、资源选择等具有完整交互边界的能力放在节点目录的 `component/` 下;
节点入口负责统一写回节点属性和执行节点级校验。

自定义工具节点的参数列表、Python 代码与返回内容由节点入口维护,参数弹窗放在
`tool-custom-node/component/InputFieldDialog.vue`,同时封装标题栏的添加按钮,编辑入口调用其 `open(data, index)`。
组件通过 `submit(data, index?)` 提交,节点写回后
调用 `close()`。新增与编辑参数继续按来源重置参数值,保留旧节点在流程末尾时的返回内容兼容逻辑。

变量拆分节点的 `VariableFieldTable` 通过 `v-model` 接收 `VariableField[]`,只负责列表增删改、
重名检查和编辑弹窗,不接收节点模型或读写 `node_data`。节点入口在列表写回时同步输出字段并
清理下游失效引用;字段类型由该组件目录的 `types.ts` 统一定义。
Expand Down
2 changes: 0 additions & 2 deletions ui/src/workflow-canvas/config/node-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,6 @@ export const mcpNode = {
type: WorkflowNodeType.McpNode,
text: '通过 SSE/Streamable HTTP 方式执行MCP服务中的工具',
label: 'MCP 调用',
height: 252,
properties: { stepName: 'MCP 调用', config: { fields: [{ label: '结果', value: 'result' }] } },
}

Expand All @@ -359,7 +358,6 @@ export const toolCustomNode = {
type: WorkflowNodeType.ToolLibCustom,
text: '通过执行自定义脚本,实现数据处理',
label: '自定义工具',
height: 260,
properties: { stepName: '自定义工具', config: { fields: [{ label: '结果', value: 'result' }] } },
}

Expand Down
4 changes: 2 additions & 2 deletions ui/src/workflow-canvas/icons/mcp-node-icon.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<el-avatar class="ai-avatar-gradient" shape="square">
<img src="@/assets/workflow/icon_ai_chat.svg" style="width: 75%" alt="" />
<el-avatar shape="square">
<img src="@/assets/tool/icon_mcp.svg" style="width: 75%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
6 changes: 6 additions & 0 deletions ui/src/workflow-canvas/icons/tool-node-icon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<el-avatar shape="square" class="bg-success!">
<img src="@/assets/workflow/icon_tool_custom.svg" style="width: 64%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
Original file line number Diff line number Diff line change
@@ -1,49 +1,60 @@
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue'
import { ref, useTemplateRef } from 'vue'
import { cloneDeep } from 'lodash'
import type { FormInstance, FormRules } from 'element-plus'
import type { ToolInputField, ToolInputFieldType } from '@/api/types'

defineOptions({ name: 'WorkflowToolInputFieldDialog' })

const emit = defineEmits<{ refresh: [field: ToolInputField] }>()
const emit = defineEmits<{ submit: [field: ToolInputField, index?: number] }>()

const inputFieldTypes: ToolInputFieldType[] = ['string', 'int', 'float', 'dict', 'array']
const formRef = ref<FormInstance>()
const formRef = useTemplateRef<FormInstance>('formRef')
const visible = ref(false)
const editing = ref(false)
const inputFieldForm = reactive<ToolInputField>({ desc: '', is_required: true, name: '', source: 'reference', type: 'string' })
const currentIndex = ref<number>()
const inputFieldForm = ref<ToolInputField>({ desc: '', is_required: true, name: '', source: 'reference', type: 'string' })
const formRules: FormRules<ToolInputField> = {
name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }],
}

function open(field?: ToolInputField) {
editing.value = Boolean(field)
if (field) Object.assign(inputFieldForm, cloneDeep(field))
// 弹窗仅维护参数草稿,节点接收提交后关闭。
function open(field?: ToolInputField, index?: number) {
resetData()
if (field) {
inputFieldForm.value = cloneDeep(field)
editing.value = true
currentIndex.value = index
}
visible.value = true
nextTick(() => formRef.value?.clearValidate())
}

function handleSubmit() {
formRef.value?.validate((valid) => {
if (!valid) return
emit('refresh', cloneDeep(inputFieldForm))
visible.value = false
})
formRef.value
?.validate()
.then(() => emit('submit', cloneDeep(inputFieldForm.value), currentIndex.value))
.catch(() => {})
}

function close() {
visible.value = false
}

function resetData() {
editing.value = false
Reflect.deleteProperty(inputFieldForm, 'value')
Object.assign(inputFieldForm, { desc: '', is_required: true, name: '', source: 'reference', type: 'string' })
currentIndex.value = undefined
inputFieldForm.value = { desc: '', is_required: true, name: '', source: 'reference', type: 'string' }
formRef.value?.clearValidate()
}

defineExpose({ open })
defineExpose({ open, close })
</script>

<template>
<MkDialog v-model="visible" :title="editing ? '编辑参数' : '添加参数'" width="500" @closed="resetData">
<el-button text type="primary" @click="open()">
<MkIcon name="icon_add_outlined" />
</el-button>
<MkDialog v-model="visible" :title="editing ? '编辑参数' : '添加参数'" @closed="resetData">
<el-form ref="formRef" :model="inputFieldForm" :rules="formRules" label-position="top" require-asterisk-position="right" @submit.prevent>
<el-form-item label="参数名称" prop="name">
<el-input
Expand Down
164 changes: 84 additions & 80 deletions ui/src/workflow-canvas/nodes/tool-custom-node/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, useTemplateRef } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import { computed, inject, onMounted, useTemplateRef } from 'vue'
import { cloneDeep } from 'lodash'
import type { FormInstance } from 'element-plus'
import type { ToolInputField } from '@/api/types'
import PythonCodeEditor from '@/components/codemirror-editor/python.vue'
Expand All @@ -9,7 +9,7 @@ import NodeContainer from '@/workflow-canvas/core/node-container/index.vue'
import { handleNodeWheel, isLastNode } from '@/workflow-canvas/core/utils'
import type { WorkflowNodeModel } from '@/workflow-canvas/core/workflow-node'
import { WorkflowMode } from '@/workflow-canvas/types'
import InputFieldDialog from './InputFieldDialog.vue'
import FieldSetting from './component/FieldSetting.vue'

defineOptions({ name: 'WorkflowToolCustomNode' })

Expand All @@ -20,7 +20,7 @@ type ToolNodeInputField =
interface ToolCustomNodeForm {
code: string
input_field_list: ToolNodeInputField[]
is_result: boolean
is_result?: boolean
}

const getModel = inject('getModel') as () => WorkflowNodeModel
Expand All @@ -29,13 +29,16 @@ const model = getModel()

const formRef = useTemplateRef<FormInstance>('formRef')
const inputFieldDialogRef = useTemplateRef<InstanceType<typeof InputFieldDialog>>('inputFieldDialogRef')
const currentFieldIndex = ref<number>()

// 初始化节点配置,保留旧节点缺少返回内容开关时的兼容逻辑。
const defaultForm: ToolCustomNodeForm = { code: '', input_field_list: [], is_result: false }
const savedForm = model.properties.node_data as Partial<ToolCustomNodeForm> | undefined
model.properties.node_data = {
code: savedForm?.code ?? '',
input_field_list: Array.isArray(savedForm?.input_field_list) ? savedForm.input_field_list : [],
is_result: savedForm ? savedForm.is_result : false,
...defaultForm,
...savedForm,
code: savedForm?.code ?? defaultForm.code,
input_field_list: Array.isArray(savedForm?.input_field_list) ? savedForm.input_field_list : defaultForm.input_field_list,
is_result: savedForm ? savedForm.is_result : defaultForm.is_result,
}

const formData = computed<ToolCustomNodeForm>({
Expand All @@ -47,29 +50,33 @@ const showReturnContent = computed(() =>
[WorkflowMode.Application, WorkflowMode.ApplicationLoop, WorkflowMode.Tool, WorkflowMode.ToolLoop].includes(workflowMode),
)

function validate() {
return formRef.value?.validate().catch((error) => Promise.reject({ node: model, errMessage: error })) ?? Promise.resolve()
}

// 参数配置由弹窗提交,节点入口统一写回;编辑后沿用原有的值重置行为。
function handleOpenInputField(field?: ToolNodeInputField, index?: number) {
currentFieldIndex.value = index
inputFieldDialogRef.value?.open(field)
inputFieldDialogRef.value?.open(field, index)
}

function handleDeleteInputField(index: number) {
formData.value.input_field_list.splice(index, 1)
const inputFields = cloneDeep(formData.value.input_field_list)
inputFields.splice(index, 1)
formData.value = { ...formData.value, input_field_list: inputFields }
}

function handleInputFieldRefresh(field: ToolInputField) {
function handleInputFieldSubmit(field: ToolInputField, index?: number) {
const inputField: ToolNodeInputField =
field.source === 'reference' ? { ...field, source: 'reference', value: [] } : { ...field, source: 'custom', value: '' }
const inputFields = cloneDeep(formData.value.input_field_list)

if (currentFieldIndex.value === undefined) {
formData.value.input_field_list.push(inputField)
if (index === undefined) {
inputFields.push(inputField)
} else {
formData.value.input_field_list.splice(currentFieldIndex.value, 1, inputField)
inputFields.splice(index, 1, inputField)
}
currentFieldIndex.value = undefined
formData.value = { ...formData.value, input_field_list: inputFields }
inputFieldDialogRef.value?.close()
}

function validate() {
return formRef.value?.validate().catch((error) => Promise.reject({ node: model, errMessage: error })) ?? Promise.resolve()
}

onMounted(() => {
Expand All @@ -80,74 +87,71 @@ onMounted(() => {

<template>
<NodeContainer :node-model="model">
<h6 class="mb-3">节点设置</h6>
<h6 class="mk-title-decoration mb-2">节点设置</h6>

<el-form ref="formRef" :model="formData" label-position="top" hide-required-asterisk @submit.prevent>
<div class="mb-2 flex-between">
<h6>输入参数</h6>
<el-button link type="primary" @click="handleOpenInputField()">
<MkIcon name="icon_add_outlined" class="mr-1" />
添加
</el-button>
</div>
<div class="mk-gray-card">
<!-- 输入参数 -->
<div class="flex-between mb-2">
<p>输入参数</p>
<FieldSetting ref="inputFieldDialogRef" @submit="handleInputFieldSubmit" />
</div>

<el-card shadow="never" class="card-never mb-4" style="--el-card-padding: 12px">
<template v-if="formData.input_field_list.length">
<el-form-item
v-for="(field, index) in formData.input_field_list"
:key="`${field.name}-${index}`"
:prop="`input_field_list.${index}.value`"
:rules="{
required: field.is_required,
message: field.source === 'reference' ? '请选择参数' : '请输入参数',
trigger: field.source === 'reference' ? 'change' : 'blur',
}"
>
<template #label>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-1">
<span class="max-w-32 truncate" :title="field.name">{{ field.name }}</span>
<el-tooltip v-if="field.desc" :content="field.desc" effect="dark" placement="right">
<MkIcon :icon="QuestionFilled" class="cursor-help text-N600" />
</el-tooltip>
<span v-if="field.is_required" class="text-danger">*</span>
<el-tag size="small" type="info">{{ field.type }}</el-tag>
</div>

<div class="flex shrink-0 items-center">
<el-button text type="primary" @click.stop="handleOpenInputField(field, index)">
<MkIcon name="icon_edit_outlined" />
</el-button>
<el-button text type="primary" @click="handleDeleteInputField(index)">
<MkIcon name="icon_delete-trash_outlined" />
</el-button>
<div class="mk-white-card">
<el-form-item
v-for="(field, index) in formData.input_field_list"
:key="`${field.name}-${index}`"
:prop="`input_field_list.${index}.value`"
:rules="{
required: field.is_required,
message: field.source === 'reference' ? '请选择参数' : '请输入参数',
trigger: field.source === 'reference' ? 'change' : 'blur',
}"
>
<template #label>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-1">
<span class="max-w-32 truncate" :class="{ 'mk-required': field.is_required }" :title="field.name">{{ field.name }}</span>
<el-tooltip v-if="field.desc" :content="field.desc" effect="dark" placement="right">
<MkIcon name="icon_info_outlined" class="text-N600!" />
</el-tooltip>
<el-tag size="small" type="info">{{ field.type }}</el-tag>
</div>

<div class="flex shrink-0 items-center">
<el-button text type="primary" @click.stop="handleOpenInputField(field, index)">
<MkIcon name="icon_edit_outlined" />
</el-button>
<el-button text type="primary" @click="handleDeleteInputField(index)">
<MkIcon name="icon_delete-trash_outlined" />
</el-button>
</div>
</div>
</div>
</template>
</template>

<NodeCascader v-if="field.source === 'reference'" v-model="field.value" :node-model="model" placeholder="请选择参数" />
<el-input v-else v-model="field.value" placeholder="请输入参数" />
</el-form-item>
</template>
<MkEmpty v-else :image-size="60" />
</el-card>

<h6 class="mb-2">Python 代码</h6>
<PythonCodeEditor v-model="formData.code" class="h-32" title="Python 代码" @wheel="handleNodeWheel" />

<el-form-item v-if="showReturnContent" label="返回内容" class="mt-4" @click.prevent>
<template #label>
<div class="flex items-center gap-1">
<span>返回内容</span>
<el-tooltip content="开启后,该节点的输出会作为工作流的最终回复内容" effect="dark" placement="right">
<MkIcon :icon="QuestionFilled" class="cursor-help text-N600" />
</el-tooltip>
<NodeCascader v-if="field.source === 'reference'" v-model="field.value" :node-model="model" placeholder="请选择参数" />
<el-input v-else v-model="field.value" placeholder="请输入参数" />
</el-form-item>
</div>
</template>
<el-switch v-model="formData.is_result" size="small" />
</el-form-item>
<!-- 输入参数 -->
<el-form-item label="工具内容(Python)" class="mt-4">
<PythonCodeEditor v-model="formData.code" title="工具内容(Python)" @wheel="handleNodeWheel" />
</el-form-item>
<!-- 返回内容 -->
<div class="flex-between w-full" v-if="showReturnContent">
<span class="flex items-center gap-1">
返回内容
<el-tooltip content="关闭后该节点的内容则不输出给用户。如果你想让用户看到该节点的输出内容,请打开开关。" placement="right">
<MkIcon name="icon_info_outlined" class="text-N600!" />
</el-tooltip>
</span>
<span>
<el-switch v-model="formData.is_result" size="small" />
</span>
</div>
</div>
</el-form>

<InputFieldDialog ref="inputFieldDialogRef" @refresh="handleInputFieldRefresh" />
</NodeContainer>
</template>
2 changes: 1 addition & 1 deletion ui/src/workflow-canvas/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export enum WorkflowNodeType {
Reply = 'reply-node',
ToolLib = 'tool-lib-node',
ToolWorkflowLib = 'tool-workflow-lib-node',
ToolLibCustom = 'tool-node',
ToolLibCustom = 'tool-node', //自定义工具
RerankerNode = 'reranker-node',
Application = 'application-node',
DocumentExtractNode = 'document-extract-node',
Expand Down
Loading