1. 上游节点输出引用(核心功能)

- 新增 upstreamNodes computed:遍历当前选中节点的前驱链路,仅 model / http / form 三类节点被识别为有输出的节点
  - 新增 getNodeOutputFields,按节点类型提取可引用字段:
    - form → formConfig 的字段
    - http → response schema 的叶子字段
    - model → modelResponseBodyMapping(模型返回参数)的 key
    - 开始节点 → runFormFields(运行表单字段),从而让下游节点能引用"用户填的运行表单"作为输出

  2. 开始节点运行表单字段聚合
  - syncRunFormFields + 深监听:开始节点 formConfig 变化时自动聚合字段,存为 runFormFields,作为开始节点自身可被下游引用的输出

  3. 模型返回参数
  - handleModelConfirm 用 stripReadonlyFields 清理 requestBodyMapping 冗余字段,同时保存 modelResponseBodyMapping 供下游引用
  - 保存时 collectExposedFields 提取对外暴露字段存 modelFormFields

  4. 提示词 / 反向提示词
  - 保存 prompt、negativePrompt、runFormFields 到节点 DSL

  5. 其他
  - nodeTypes 用 markRaw 包裹 FlowNode,修复 Vue 警告
  - buildNodeFormConfigFromDsl 的 responseType 匹配改为兼容 key/value 两种取值
This commit is contained in:
2026-08-12 17:26:58 +08:00
parent 4e1cd284e9
commit 0e6381d2b3
9 changed files with 1661 additions and 50 deletions
+3
View File
@@ -25,6 +25,7 @@ export interface NodeLibraryItem {
postToolOption: boolean;
skillOption: boolean;
promptOption: boolean;
negativePromptOption: boolean;
isSaveFileOption: boolean;
formConfigOption: boolean;
modelConfigOption: boolean;
@@ -51,6 +52,8 @@ export interface WorkflowModelItem {
baseUrl?: string;
enabled?: number | boolean;
isOwner?: number;
// 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致)
requestBodyMapping?: Record<string, any>;
[key: string]: any;
}
@@ -36,15 +36,45 @@
</div>
</el-form-item>
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
<template v-if="form.jsonType === 'boolean'">
<el-select v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
<template v-if="hasReference">
<div class="fcd-ref-bound" :class="{ 'is-invalid': referenceInvalid }">
<span class="fcd-ref-bound-text">已引用{{ referenceLabel }}</span>
<el-button size="small" text type="danger" @click="clearReference">清除</el-button>
</div>
</template>
<template v-else>
<div class="fcd-default-row">
<el-select v-if="form.jsonType === 'boolean'" v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
<el-popover v-if="hasUpstream" placement="bottom" :width="260" trigger="click" popper-class="fcd-ref-popover">
<template #reference>
<el-button size="small" text type="primary" class="fcd-ref-btn" title="引用上级节点输出">
<el-icon><Link /></el-icon>
</el-button>
</template>
<div class="fcd-ref-panel">
<div v-if="!hasUpstream" class="fcd-ref-empty">无可引用的上级节点</div>
<template v-else>
<div v-for="node in upstreamNodes" :key="node.id" class="fcd-ref-node">
<div class="fcd-ref-node-head">
<span class="fcd-ref-node-name">{{ node.label }}</span>
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
</div>
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setReference(node, of)">
{{ of.label || of.field }}
</div>
</div>
</template>
</div>
</el-popover>
</div>
</template>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
</el-form-item>
<template v-if="jsonTypeIsScalar">
<template v-if="jsonTypeIsScalar && !hideFormConfig">
<el-divider content-position="left">表单属性</el-divider>
<el-form-item label="表单显示">
<el-switch v-model="form.isForm" />
@@ -147,19 +177,49 @@
</div>
</el-form-item>
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
<template v-if="form.jsonType === 'boolean'">
<el-select v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
<template v-if="hasReference">
<div class="fcd-ref-bound" :class="{ 'is-invalid': referenceInvalid }">
<span class="fcd-ref-bound-text">已引用{{ referenceLabel }}</span>
<el-button size="small" text type="danger" @click="clearReference">清除</el-button>
</div>
</template>
<template v-else>
<div class="fcd-default-row">
<el-select v-if="form.jsonType === 'boolean'" v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
<el-popover v-if="hasUpstream" placement="bottom" :width="260" trigger="click" popper-class="fcd-ref-popover">
<template #reference>
<el-button size="small" text type="primary" class="fcd-ref-btn" title="引用上级节点输出">
<el-icon><Link /></el-icon>
</el-button>
</template>
<div class="fcd-ref-panel">
<div v-if="!hasUpstream" class="fcd-ref-empty">无可引用的上级节点</div>
<template v-else>
<div v-for="node in upstreamNodes" :key="node.id" class="fcd-ref-node">
<div class="fcd-ref-node-head">
<span class="fcd-ref-node-name">{{ node.label }}</span>
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
</div>
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setReference(node, of)">
{{ of.label || of.field }}
</div>
</div>
</template>
</div>
</el-popover>
</div>
</template>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
</el-form-item>
<template v-if="jsonTypeIsScalar">
<template v-if="jsonTypeIsScalar && !hideFormConfig">
<el-divider content-position="left">表单属性</el-divider>
<el-form-item label="表单显示">
<el-form-item label="是否可编辑">
<el-switch v-model="form.isForm" />
<span class="form-hint">{{ form.isForm ? '在表单中展示' : '隐藏字段' }}</span>
<span class="form-hint">{{ form.isForm ? '可编辑' : '只读' }}</span>
</el-form-item>
<el-form-item label="表单控件">
<el-select v-model="form.fieldType" @change="onTypeChange">
@@ -215,7 +275,7 @@
</el-dialog>
</template>
<!-- Enum editor dialog -->
<el-dialog v-model="enumDialogVisible" title="枚举值配置" width="640px" :close-on-click-modal="false" destroy-on-close>
<el-dialog v-model="enumDialogVisible" title="枚举值配置" width="900px" :close-on-click-modal="false" destroy-on-close>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px;">
<template #default>
编辑数组字段允许的固定值列表每个值为一个允许的枚举项
@@ -234,12 +294,20 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, defineAsyncComponent } from 'vue';
import { ElMessageBox } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { Plus, Link } from '@element-plus/icons-vue';
const JsonEditor = defineAsyncComponent(() => import('./index.vue'));
interface UploadRule { format: string; maxSize?: number; maxCount?: number; }
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
interface FieldFormData {
nodeKey: string; nodeId: string; jsonType: string; fieldType: string;
label: string; description: string; role: string;
@@ -251,6 +319,7 @@ interface FieldFormData {
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: UploadRule[]; uploadTotalMaxCount?: number; uploadTotalMaxSize?: number;
};
reference?: { nodeId: string; field: string };
_createParentId?: string; _isArrayParent?: boolean; _childrenCount?: number;
}
@@ -260,6 +329,8 @@ const props = defineProps<{
fieldTypeOptions: { label: string; value: string }[];
hasConfig: boolean;
panel?: boolean;
upstreamNodes?: UpstreamNodeInfo[];
hideFormConfig?: boolean;
}>();
const emit = defineEmits<{
@@ -273,6 +344,7 @@ const form = reactive<FieldFormData>({
label: '', description: '', role: '',
isForm: true, required: false, defaultValue: '', options: [], constraint: {},
enumValues: [],
reference: undefined,
});
const uploadRules = ref<UploadRule[]>([]);
@@ -295,6 +367,7 @@ function resetForm() {
form.isForm = true; form.required = false; form.defaultValue = '';
form.options = []; form.constraint = {};
form.enumValues = [];
form.reference = undefined;
// @ts-ignore
form._createParentId = undefined; form._isArrayParent = false;
uploadRules.value = [];
@@ -316,6 +389,7 @@ function loadForm(data: FieldFormData | null) {
}
form.constraint = data.constraint ? { ...data.constraint } : {};
form.enumValues = data.enumValues ? JSON.parse(JSON.stringify(data.enumValues)) : [];
form.reference = data.reference ? { ...data.reference } : undefined;
// @ts-ignore
form._createParentId = data._createParentId; form._isArrayParent = data._isArrayParent ?? false;
// @ts-ignore
@@ -371,6 +445,38 @@ function onTypeChange() {
watch(() => props.visible, (val) => { if (val) loadForm(props.formData); }, { immediate: true });
// ===== 默认值引用上级节点输出 =====
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
const hasReference = computed(() => !!form.reference);
const referenceLabel = computed(() => {
const ref = form.reference;
if (!ref) return '';
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
const nodeName = node?.label || ref.nodeId;
const fieldLabel = node?.outputFields.find((f) => f.field === ref.field)?.label || ref.field;
return `${nodeName}.${fieldLabel}`;
});
// 引用是否已失效:上游节点被删除,或其输出字段已不存在
const referenceInvalid = computed(() => {
const ref = form.reference;
if (!ref) return false;
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
if (!node) return true;
return !node.outputFields.some((f) => f.field === ref.field);
});
watch(referenceInvalid, (invalid) => {
if (invalid && form.reference) { form.reference = undefined; }
}, { immediate: true });
const setReference = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
form.reference = { nodeId: node.id, field: of.field };
// 有引用时默认值无意义,清空避免两者并存导致歧义
form.defaultValue = '';
};
const clearReference = () => {
form.reference = undefined;
};
function handleSave() {
syncUploadRulesToConstraint();
emit('save', { ...form, constraint: { ...form.constraint } });
@@ -398,7 +504,7 @@ function handleClosed() { resetForm(); }
}
.form-hint { font-size: 11px; color: #94a3b8; margin-left: 8px; }
.enum-config-inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.enum-editor-container { height: 360px; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; }
.enum-editor-container { height: 500px; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; }
.options-editor { width: 100%;
.option-row { display: flex; gap: 4px; margin-bottom: 4px; }
.add-option-btn { font-size: 12px; margin-top: 4px; }
@@ -408,4 +514,84 @@ function handleClosed() { resetForm(); }
.add-rule-btn { font-size: 12px; margin-top: 2px; }
}
:deep(.el-form-item) { margin-bottom: 12px; }
.fcd-default-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
.el-select, .el-input { flex: 1; min-width: 0; }
}
.fcd-ref-bound {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #3b82f6;
background: #eff6ff;
border-radius: 4px;
padding: 4px 8px;
.fcd-ref-bound-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-invalid {
color: #dc2626;
background: #fef2f2;
}
}
</style>
<!-- popover teleport body面板样式需脱离 scoped -->
<style lang="scss">
.fcd-ref-popover {
.fcd-ref-panel {
.fcd-ref-empty {
color: #94a3b8;
font-size: 12px;
padding: 6px 0;
text-align: center;
}
.fcd-ref-node {
& + .fcd-ref-node {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
}
.fcd-ref-node-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
.fcd-ref-node-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.fcd-ref-node-empty {
font-size: 12px;
color: #cbd5e1;
padding: 2px 0 2px 8px;
}
.fcd-ref-field {
font-size: 12px;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: #eff6ff;
color: #3b82f6;
}
}
}
}
}
</style>
+21 -1
View File
@@ -17,6 +17,8 @@
:form-data="fieldDialog.formData"
:field-type-options="fieldDialog.typeOptions"
:has-config="fieldDialog.hasConfig"
:upstream-nodes="props.upstreamNodes"
:hide-form-config="props.hideFormConfig"
@update:visible="clearSelection"
@save="handleFieldSave"
@remove="handleFieldRemove"
@@ -60,7 +62,11 @@ import FieldConfigDialog from './FieldConfigDialog.vue';
defineOptions({ name: 'JsonEditor' });
const props = defineProps<{ modelValue: any }>();
const props = defineProps<{
modelValue: any;
upstreamNodes?: UpstreamNodeInfo[];
hideFormConfig?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: any): void;
(e: 'change', value: any): void;
@@ -78,6 +84,7 @@ interface FieldConfig {
defaultValue?: string | number;
options?: Array<{ label: string; value: string }>;
enumValues?: any[];
reference?: { nodeId: string; field: string };
constraint?: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
@@ -97,6 +104,14 @@ interface JsonNodeData {
_isWrapped?: boolean;
}
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
interface FieldFormData {
nodeKey: string;
nodeId: string;
@@ -115,6 +130,7 @@ interface FieldFormData {
_createParentId?: string;
_isArrayParent?: boolean;
_childrenCount?: number;
reference?: { nodeId: string; field: string };
}
// --- helpers ---
@@ -298,6 +314,7 @@ function formToConfig(form: FieldFormData): FieldConfig {
if (form.defaultValue) cfg.defaultValue = form.jsonType === 'number' ? Number(form.defaultValue) : form.defaultValue;
if (form.options?.length > 0) cfg.options = [...form.options];
if (form.enumValues?.length > 0) cfg.enumValues = JSON.parse(JSON.stringify(form.enumValues));
if (form.reference) cfg.reference = { ...form.reference };
const hasC = Object.values(form.constraint).some((v: any) => {
if (Array.isArray(v)) return v.length > 0;
return v !== undefined && v !== null && v !== '';
@@ -318,6 +335,7 @@ function hasAnyConfig(node: JsonNodeData): boolean {
if (c.label || c.description || c.role || c.defaultValue) return true;
if (c.options?.length) return true;
if (c.enumValues?.length) return true;
if (c.reference) return true;
if (c.required) return true;
if (c.isForm === false) return true;
if (c.constraint && Object.keys(c.constraint).length > 0) return true;
@@ -348,6 +366,7 @@ function openFieldDialog(nodeId: string) {
options: cfg.options || [],
constraint: cfg.constraint ? { ...cfg.constraint } : {},
enumValues: cfg.enumValues ? JSON.parse(JSON.stringify(cfg.enumValues)) : [],
reference: cfg.reference ? { ...cfg.reference } : undefined,
_childrenCount: node.children.length,
},
};
@@ -380,6 +399,7 @@ function openAddChildDialog(parentNodeId: string) {
options: [],
constraint: {},
enumValues: [],
reference: undefined,
_createParentId: parentNodeId,
_isArrayParent: isArray,
_childrenCount: 0,
@@ -0,0 +1,612 @@
<template>
<div class="model-field">
<!-- object递归渲染 attrs 子字段 -->
<template v-if="isObject">
<div class="mf-block">
<div class="mf-block-title">
<el-icon><FolderOpened /></el-icon>
<span class="mf-block-name">{{ label }}</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="mf-block-body">
<ModelField
v-for="(childDef, childKey) in objectChildren"
:key="childKey"
:field-def="childDef"
:path="path ? `${path}.attrs.${childKey}` : childKey"
:upstream-nodes="upstreamNodes"
/>
</div>
</div>
</template>
<!-- array直接平铺展示元素子字段用户直接填写不提供添加/删除 -->
<template v-else-if="isArray">
<div class="mf-block">
<div class="mf-block-title">
<el-icon><List /></el-icon>
<span class="mf-block-name">{{ label }}</span>
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="mf-array-flat">
<!-- 联合数组enumValues 多模板全部模板平铺固定条数不可增删 -->
<template v-if="isVariantArray">
<div v-for="(item, idx) in getVariantItems()" :key="idx" class="mf-variant">
<div class="mf-variant-head">
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
</div>
<template v-if="isObjectDef(item)">
<ModelField
v-for="(subDef, subKey) in item.attrs"
:key="subKey"
:field-def="subDef"
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
</div>
</template>
<!-- 同构数组保持现有平铺逻辑 -->
<template v-else>
<template v-for="(item, idx) in getArrayValue()" :key="idx">
<template v-if="isObjectDef(item)">
<div v-if="getArrayValue().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
<ModelField
v-for="(subDef, subKey) in item.attrs"
:key="subKey"
:field-def="subDef"
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
</template>
</template>
</div>
</div>
</template>
<!-- 叶子 fieldType 渲染控件 -->
<template v-else>
<div class="mf-leaf">
<div class="mf-leaf-label">
<span class="mf-label-text">{{ label }}</span>
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
<el-tooltip :content="path || label" placement="top">
<el-checkbox
size="small"
:model-value="def.runtimeShow ?? false"
:disabled="hasReference"
@change="toggleExpose"
class="mf-leaf-expose"
>表单展示</el-checkbox>
</el-tooltip>
<el-popover
v-if="canReference"
placement="bottom"
:width="260"
trigger="click"
popper-class="mf-ref-popover"
>
<template #reference>
<el-button
size="small"
text
type="primary"
class="mf-ref-btn"
title="引用上级节点输出"
:disabled="!!def.runtimeShow || !hasUpstream"
>
<el-icon><Link /></el-icon>
</el-button>
</template>
<div class="mf-ref-panel">
<div v-if="!hasUpstream" class="mf-ref-empty">无可引用的上级节点</div>
<template v-else>
<div v-for="node in upstreamNodes" :key="node.id" class="mf-ref-node">
<div class="mf-ref-node-head">
<span class="mf-ref-node-name">{{ node.label }}</span>
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
</div>
<div v-if="node.outputFields.length === 0" class="mf-ref-node-empty">该节点无输出字段</div>
<div
v-for="of in node.outputFields"
:key="of.field"
class="mf-ref-field"
@click="setReference(node, of)"
>
{{ of.label || of.field }}
</div>
</div>
</template>
</div>
</el-popover>
</div>
<div v-if="hasReference" class="mf-ref-bound" :class="{ 'is-invalid': referenceInvalid }">
<span class="mf-ref-bound-text">已引用{{ referenceLabel }}</span>
<el-button size="small" text type="danger" @click="clearReference">清除</el-button>
</div>
<div v-else-if="!isUpload" class="mf-leaf-ctrl">
<el-input-number
v-if="isNumber"
:model-value="numberValue"
@change="setValue"
class="mf-ctrl"
:controls="false"
:placeholder="required ? '必填' : ''"
/>
<el-switch v-else-if="isSwitch" :model-value="switchValue" @change="setValue" class="mf-ctrl" />
<el-select
v-else-if="isSelect"
:model-value="def.value ?? ''"
@change="setSelectValue"
class="mf-ctrl"
clearable
:placeholder="required ? '必填' : ''"
>
<el-option v-for="opt in def.options" :key="opt.value" :label="opt.label ?? opt.value" :value="opt.value" />
</el-select>
<el-input
v-else-if="isTextarea"
:model-value="def.value ?? ''"
@input="setValue"
type="textarea"
:rows="2"
class="mf-ctrl"
:placeholder="required ? '必填' : ''"
/>
<el-input
v-else
:model-value="def.value ?? ''"
@input="setValue"
class="mf-ctrl"
:placeholder="required ? '必填' : ''"
/>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { FolderOpened, List, QuestionFilled, Link } from '@element-plus/icons-vue';
import { deepClone, normalizeArrayItem } from './modelParamUtils';
defineOptions({ name: 'ModelField' });
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[] }>();
// 用 computed 实时取当前 props,避免 internalSync 替换对象树后仍持有旧引用
const def = computed(() => props.fieldDef);
// ===== 基础元信息 =====
const label = computed(() => def.value?.label || def.value?.field || '');
const description = computed(() => def.value?.description || '');
const required = computed(() => !!def.value?.required);
const defType = computed(() => def.value?.type || 'string');
const fieldType = computed(() => def.value?.fieldType || 'string');
// ===== 类型判定 =====
const isObject = computed(() => defType.value === 'object');
const isArray = computed(() => defType.value === 'array');
const isLeaf = computed(() => !isObject.value && !isArray.value);
const objectChildren = computed<Record<string, any>>(() => {
if (!isObject.value) return {};
const attrs = def.value?.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) return attrs;
return {};
});
const isNumber = computed(() => isLeaf.value && (fieldType.value === 'number' || defType.value === 'number'));
const isSwitch = computed(() => isLeaf.value && (fieldType.value === 'switch' || defType.value === 'boolean'));
const isSelect = computed(() => isLeaf.value && fieldType.value === 'select' && Array.isArray(def.value?.options));
const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea');
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
// ===== 引用上级节点输出 =====
// 可引用:叶子 + 非开关 + 非固定选项下拉 + 非上传(开关/下拉值来自模型;上传类型不提供引用)
const canReference = computed(() => isLeaf.value && !isSwitch.value && !isSelect.value && !isUpload.value);
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
const hasReference = computed(() => !!def.value?.reference);
// 已引用时的展示文案:{节点label}.{字段label}
const referenceLabel = computed(() => {
const ref = def.value?.reference;
if (!ref) return '';
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
const nodeName = node?.label || ref.nodeId;
const fieldLabel = node?.outputFields.find((f) => f.field === ref.field)?.label || ref.field;
return `${nodeName}.${fieldLabel}`;
});
// 引用是否已失效:上游节点被删除,或其输出字段已不存在(换模型/换配置导致 schema 变化)
const referenceInvalid = computed(() => {
const ref = def.value?.reference;
if (!ref) return false;
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
if (!node) return true;
return !node.outputFields.some((f) => f.field === ref.field);
});
// 失效引用自动清理,避免保存脏数据(就地删除 reference,复用深 watch 上传链路)
watch(
referenceInvalid,
(invalid) => {
if (invalid && def.value) {
delete def.value.reference;
}
},
{ immediate: true }
);
// 选中前驱节点输出字段:就地写 def.reference,复用深 watch 上传链路
const setReference = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
if (!def.value) return;
def.value.reference = { nodeId: node.id, field: of.field };
};
const clearReference = () => {
if (!def.value) return;
delete def.value.reference;
};
// ===== 叶子值(兼容字符串/布尔/数字) =====
const numberValue = computed(() => {
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
const n = Number(v);
return Number.isNaN(n) ? 0 : n;
});
const switchValue = computed(() => {
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
return v === true || v === 'true';
});
const setValue = (val: any) => {
if (!def.value) return;
if (isSwitch.value) {
def.value.value = val === true || val === 'true';
} else if (isNumber.value) {
def.value.value = val === null || val === undefined || val === '' ? 0 : Number(val);
} else {
def.value.value = val ?? '';
}
};
const setSelectValue = (val: any) => {
if (!def.value) return;
def.value.value = val ?? '';
};
// 勾选「表单展示」:就地写 def.runtimeShow,复用深 watch 上传链路
const toggleExpose = (checked: any) => {
if (!def.value) return;
def.value.runtimeShow = !!checked;
};
// ===== array:平铺展示元素子字段,供用户直接填写;不提供添加/删除 =====
// 元素模板:优先 enumValues 首个,其次 attrs 数组,其次单个 attrs 对象
const arrayTemplates = computed<any[]>(() => {
if (!isArray.value || !def.value) return [];
if (Array.isArray(def.value.enumValues) && def.value.enumValues.length) return def.value.enumValues;
if (Array.isArray(def.value.attrs) && def.value.attrs.length) return def.value.attrs;
if (def.value.attrs && typeof def.value.attrs === 'object' && !Array.isArray(def.value.attrs)) {
return [{ type: 'object', attrs: def.value.attrs }];
}
return [];
});
const getArrayValue = (): any[] => {
if (!def.value) return [];
if (!Array.isArray(def.value.value)) {
def.value.value = [];
}
// value 为空时(后端仅定义结构未给内容)用模板补一条空结构,保证字段可展示、可填写
if (def.value.value.length === 0 && arrayTemplates.value.length > 0) {
def.value.value.push(normalizeArrayItem(deepClone(arrayTemplates.value[0])));
}
return def.value.value;
};
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
const setArrayPrimitive = (idx: number, val: any) => {
getArrayValue()[idx] = val ?? '';
};
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
const isVariantArray = computed(
() => isArray.value && Array.isArray(def.value?.enumValues) && (def.value?.enumValues?.length ?? 0) > 1
);
// 变体名推断:优先 type 字段值(如 text/image_url/video_url),其次 role 字段描述里的角色名,兜底"变体 N"
const templateLabel = (tpl: any, index: number): string => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
const typeField = tpl.attrs.type;
if (typeField) {
const t = typeField.value ?? typeField.defaultValue ?? '';
if (t) return String(t);
}
const roleField = tpl.attrs.role;
if (roleField && roleField.description) {
const m = String(roleField.description).match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
if (m) return m[1];
}
}
return `变体 ${index + 1}`;
};
// 自动填标识值:type 用 defaultValuerole 从 description 提取;只填空值,不覆盖用户已填
const autoFillVariant = (tpl: any): void => {
if (!tpl || typeof tpl !== 'object' || !tpl.attrs || typeof tpl.attrs !== 'object' || Array.isArray(tpl.attrs)) return;
const typeField = tpl.attrs.type;
if (typeField && (typeField.value === undefined || typeField.value === null || typeField.value === '')) {
if (typeField.defaultValue !== undefined && typeField.defaultValue !== null) typeField.value = typeField.defaultValue;
}
const roleField = tpl.attrs.role;
if (roleField && (roleField.value === undefined || roleField.value === null || roleField.value === '')) {
const m = String(roleField.description || '').match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
if (m) roleField.value = m[1];
}
};
// 联合数组渲染条目:value 与 enumValues 模板按索引一一对应(懒补模板),保证固定条数
const getVariantItems = (): any[] => {
if (!isVariantArray.value || !def.value) return [];
const templates = Array.isArray(def.value.enumValues) ? def.value.enumValues : [];
if (!Array.isArray(def.value.value)) def.value.value = [];
templates.forEach((tpl: any, i: number) => {
const existing = def.value.value[i];
if (!existing || typeof existing !== 'object') {
const item = normalizeArrayItem(deepClone(tpl));
autoFillVariant(item);
def.value.value[i] = item;
}
});
if (def.value.value.length > templates.length) {
def.value.value = def.value.value.slice(0, templates.length);
}
return def.value.value;
};
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
</script>
<style scoped lang="scss">
.model-field {
width: 100%;
// 嵌套层级不重复套卡片:内层块去边框背景,用左缩进区分层级
.model-field {
.mf-block,
.mf-leaf {
border: none;
background: transparent;
padding: 0 0 0 12px;
margin-bottom: 6px;
}
}
}
.mf-block {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fbfcfe;
.mf-block-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
.mf-block-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mf-block-body {
padding-left: 4px;
}
}
.mf-info {
cursor: help;
color: #94a3b8;
font-size: 13px;
flex-shrink: 0;
}
.mf-required {
color: #f56c6c;
margin-left: 2px;
}
.mf-array-flat {
.mf-array-flat-index {
display: inline-block;
font-size: 11px;
color: #94a3b8;
background: #eef2f7;
border-radius: 999px;
padding: 0 6px;
margin: 2px 0 6px;
}
:deep(.model-field) {
margin-bottom: 0;
}
}
.mf-variant {
margin-bottom: 8px;
padding-left: 10px;
border-left: 2px solid #e2e8f0;
.mf-variant-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
.mf-variant-name {
font-size: 12px;
font-weight: 600;
color: #475569;
}
}
}
.mf-leaf {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 10px;
background: #ffffff;
.mf-leaf-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #475569;
.mf-label-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mf-leaf-expose {
flex-shrink: 0;
margin-left: 8px;
:deep(.el-checkbox__label) {
font-size: 12px;
color: #475569;
font-weight: 400;
}
}
.mf-ref-btn {
flex-shrink: 0;
margin-left: 2px;
}
}
.mf-ref-bound {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #3b82f6;
background: #eff6ff;
border-radius: 4px;
padding: 4px 8px;
.mf-ref-bound-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-invalid {
color: #dc2626;
background: #fef2f2;
}
}
.mf-leaf-ctrl {
.mf-ctrl {
width: 100%;
}
}
}
</style>
<!-- popover teleport body面板样式需脱离 scoped -->
<style lang="scss">
.mf-ref-popover {
.mf-ref-panel {
.mf-ref-empty {
color: #94a3b8;
font-size: 12px;
padding: 6px 0;
text-align: center;
}
.mf-ref-node {
& + .mf-ref-node {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
}
.mf-ref-node-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
.mf-ref-node-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mf-ref-node-empty {
font-size: 12px;
color: #cbd5e1;
padding: 2px 0 2px 8px;
}
.mf-ref-field {
font-size: 12px;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: #eff6ff;
color: #3b82f6;
}
}
}
}
}
</style>
@@ -0,0 +1,91 @@
<template>
<div class="model-params-form">
<div v-if="!hasParams" class="mpf-empty">
<el-empty description="该模型无请求参数配置" :image-size="80" />
</div>
<div v-else>
<div class="mpf-count">
{{ fieldCount }} 个参数<el-divider direction="vertical" />点击字段名旁图标查看说明
</div>
<div class="mpf-fields">
<ModelField
v-for="(def, key) in localParams"
:key="key"
:field-def="def"
:path="key"
:upstream-nodes="upstreamNodes"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import ModelField from './ModelField.vue';
import { deepClone, normalizeModelParams, stripReadonlyFields } from './modelParamUtils';
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[] }>();
const emit = defineEmits<{ 'update:modelRequestParams': [Record<string, any> | null] }>();
const localParams = ref<Record<string, any>>({});
let internalSync = false; // 由 props 同步触发,避免 emit 循环
// props → 本地:深拷贝 + defaultValue 预填,避免直接改动父级引用
watch(
() => props.modelRequestParams,
(val) => {
internalSync = true;
// 剔除 isForm=false 的只读字段:不显示、不参与编辑与保存
localParams.value = stripReadonlyFields(normalizeModelParams(deepClone(val || {})));
nextTick(() => {
internalSync = false;
});
},
{ immediate: true, deep: true }
);
// 本地 → 父级:就地修改的叶子 value 会触发深度 watch
watch(
localParams,
(val) => {
if (internalSync) return;
emit('update:modelRequestParams', deepClone(val));
},
{ deep: true }
);
const hasParams = computed(() => Object.keys(localParams.value).length > 0);
const fieldCount = computed(() => Object.keys(localParams.value).length);
</script>
<style scoped lang="scss">
.model-params-form {
width: 100%;
.mpf-empty {
padding: 8px 0;
}
.mpf-count {
font-size: 12px;
color: #94a3b8;
margin-bottom: 10px;
line-height: 1.4;
}
.mpf-fields {
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
}
</style>
@@ -17,15 +17,23 @@
<el-tag>{{ selectedNode.data?.nodeCode }}</el-tag>
</el-form-item>
<!-- 模型选择 -->
<el-form-item v-if="nodeConfig?.modelConfigOption" label="选择模型">
<el-button type="primary" @click="emit('openModelSelector')" style="width: 100%">选择模型</el-button>
<div v-if="selectedNode.data?.modelConfig?.modelName" class="selected-tag">
<el-tag type="success" size="large" closable @close="emit('removeModel')">
{{ selectedNode.data.modelConfig.modelName }}
</el-tag>
<!-- 开始节点运行表单字段摘要来自各模型节点勾选的表单展示字段 -->
<template v-if="isStartNode">
<el-divider content-position="left">运行表单字段</el-divider>
<div v-if="runFormFields.length === 0" class="run-form-empty">
暂无展示字段请在模型节点参数中勾选表单展示
</div>
</el-form-item>
<div v-else class="run-form-list">
<div v-for="(f, i) in runFormFields" :key="i" class="run-form-item">
<el-tooltip :content="f.path" placement="top">
<span class="run-form-label">{{ f.label }}</span>
</el-tooltip>
<span class="run-form-node">{{ f.nodeLabel }}</span>
<el-tag size="small" type="info" class="run-form-type">{{ fieldTypeLabel(f.fieldType) }}</el-tag>
<el-tag v-if="f.required" size="small" type="danger">必填</el-tag>
</div>
</div>
</template>
<!-- 技能选择 -->
<el-form-item v-if="nodeConfig?.skillOption" label="选择技能">
@@ -55,6 +63,24 @@
/>
</el-form-item>
<!-- 提示词promptOption -->
<el-form-item v-if="nodeConfig?.promptOption" label="提示词">
<PromptEditor
:model-value="selectedNode.data?.prompt ?? ''"
@update:model-value="updatePrompt('prompt', $event)"
/>
</el-form-item>
<!-- 反向提示词negativePromptOption -->
<el-form-item v-if="nodeConfig?.negativePromptOption" label="反向提示词">
<PromptEditor
:model-value="selectedNode.data?.negativePrompt ?? ''"
button-text="编辑反向提示词"
dialog-title="编辑反向提示词"
@update:model-value="updatePrompt('negativePrompt', $event)"
/>
</el-form-item>
<!-- 动态表单字段 -->
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
<el-divider content-position="left">节点参数</el-divider>
@@ -95,8 +121,8 @@
<el-option
v-for="opt in field.options"
:key="opt.key || opt.value"
:label="opt.key || opt.value"
:value="opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
/>
</el-select>
<KeyValueEditor
@@ -152,8 +178,8 @@
<el-option
v-for="opt in sub.options"
:key="opt.key || opt.value"
:label="opt.key || opt.value"
:value="opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
/>
</el-select>
<KeyValueEditor
@@ -181,14 +207,38 @@
<el-divider content-position="left">自定义字段</el-divider>
<FormFieldsEditor :model-value="customFormFields" @update:model-value="updateCustomFields" />
</template>
<!-- 模型选择 -->
<el-form-item v-if="nodeConfig?.modelConfigOption" label="选择模型">
<el-button type="primary" @click="emit('openModelSelector')" style="width: 100%">选择模型</el-button>
<div v-if="selectedNode.data?.modelConfig?.modelName" class="selected-tag">
<el-tag type="success" size="large" closable @close="emit('removeModel')">
{{ selectedNode.data.modelConfig.modelName }}
</el-tag>
</div>
</el-form-item>
<!-- 模型参数(选中模型后渲染 requestBodyMapping 递归表单) -->
<template v-if="nodeConfig?.modelConfigOption && selectedNode.data?.modelConfig?.modelId">
<el-divider content-position="left">模型参数</el-divider>
<ModelParamsForm
:model-request-params="selectedNode.data.modelConfig.modelRequestParams"
:upstream-nodes="upstreamNodes"
@update:model-request-params="updateModelRequestParams"
/>
</template>
</el-form>
</div>
<el-empty v-else description="请选择一个节点" :image-size="100" />
<!-- Schema 编辑器弹窗 -->
<el-dialog v-model="schemaEditorVisible" title="编辑 Schema" width="640px" append-to-body destroy-on-close>
<div style="height: 400px; overflow-y: auto">
<JsonEditor v-model="schemaEditorData" />
<el-dialog v-model="schemaEditorVisible" title="编辑 Schema" width="960px" append-to-body destroy-on-close>
<div style="height: 560px; overflow-y: auto">
<JsonEditor
v-model="schemaEditorData"
:upstream-nodes="upstreamNodes"
hide-form-config
/>
</div>
<template #footer>
<el-button @click="schemaEditorVisible = false">取消</el-button>
@@ -203,6 +253,8 @@ import { ref, computed } from 'vue';
import type { Node } from '@vue-flow/core';
import KeyValueEditor from './KeyValueEditor.vue';
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
import ModelParamsForm from './ModelParamsForm.vue';
import PromptEditor from './PromptEditor.vue';
import { JsonEditor } from '/@/components/json-schema-editor';
interface NodeData {
@@ -212,8 +264,11 @@ interface NodeData {
formConfig?: any[];
modelConfig?: any;
skillName?: string;
prompt?: string;
negativePrompt?: string;
patchLayout?: boolean;
isSaveFile?: boolean;
runFormFields?: any[];
}
interface NodeConfig {
@@ -222,12 +277,22 @@ interface NodeConfig {
formConfigOption: boolean;
skillOption: boolean;
promptOption: boolean;
negativePromptOption: boolean;
isSaveFileOption: boolean;
}
// 前驱节点的可引用输出字段(供模型参数「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
const props = defineProps<{
selectedNode: Node<NodeData, any, string> | null;
nodeConfig: NodeConfig | null;
upstreamNodes?: UpstreamNodeInfo[];
}>();
const isVideoModelSelected = computed(() => {
@@ -238,6 +303,23 @@ const isVideoModelSelected = computed(() => {
// 开始节点不展示节点描述
const isStartNode = computed(() => props.selectedNode?.data?.nodeCode === '__start__');
// 开始节点运行表单字段摘要(数据来自 index.vue 聚合写回)
const runFormFields = computed<any[]>(() => props.selectedNode?.data?.runFormFields || []);
// fieldType 可读标签
const fieldTypeLabel = (ft: string) => {
const map: Record<string, string> = {
string: '文本',
number: '数字',
switch: '开关',
boolean: '布尔',
select: '下拉',
textarea: '多行文本',
upload: '上传',
};
return map[ft] || ft || '文本';
};
// form 节点自定义字段(formConfigOption
const customFormFields = computed<FormField[]>(() => (props.selectedNode?.data?.formConfig as FormField[]) || []);
@@ -309,6 +391,19 @@ const updateIsSaveFile = (value: boolean) => {
emit('update:selectedNode', updatedNode);
};
// 更新提示词 / 反向提示词(走 update:selectedNode 链路同步到 VueFlow 与 nodes 数组)
const updatePrompt = (field: 'prompt' | 'negativePrompt', value: string) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
[field]: value,
},
};
emit('update:selectedNode', updatedNode);
};
// 自定义表单字段(formConfigOption)变更:写回 node.data.formConfig
const updateCustomFields = (fields: FormField[]) => {
if (!props.selectedNode?.data) return;
@@ -322,6 +417,22 @@ const updateCustomFields = (fields: FormField[]) => {
emit('update:selectedNode', updatedNode);
};
// 模型参数变更:写回 node.data.modelConfig.modelRequestParams
const updateModelRequestParams = (params: Record<string, any> | null) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
modelConfig: {
...props.selectedNode.data.modelConfig,
modelRequestParams: params,
},
},
};
emit('update:selectedNode', updatedNode);
};
const getFieldValue = (fieldName: string) => {
if (!props.selectedNode?.data?.formConfig) return '';
const field = props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
@@ -367,7 +478,7 @@ const getFormConfigEntry = (fieldName: string) => {
const getSelectedOptionConfig = (fieldDef: any) => {
if (fieldDef?.type !== 'select' || !Array.isArray(fieldDef.options)) return [];
const value = getFieldValue(fieldDef.field);
const opt = fieldDef.options.find((o: any) => o.value === value);
const opt = fieldDef.options.find((o: any) => o.key === value || o.value === value);
return opt?.config || [];
};
@@ -392,7 +503,10 @@ const updateExpandValue = (hostField: string, subField: string, value: any) => {
if (subIndex >= 0) {
newExpand[subIndex] = { ...newExpand[subIndex], value };
} else {
newExpand.push({ field: subField, value });
// 新建 expand 项时补 label(来源:当前选中选项的 config 定义),供下游引用面板展示可读名称
const opt = (entry.options || []).find((o: any) => o.key === entry.value || o.value === entry.value);
const cd = (opt?.config || []).find((c: any) => c.field === subField);
newExpand.push({ field: subField, label: cd?.label || subField, value });
}
const updatedFormConfig = [...formConfig];
@@ -410,7 +524,7 @@ const updateSelectFieldValue = (fieldDef: any, value: any) => {
if (!props.selectedNode?.data) return;
const formConfig = props.selectedNode.data.formConfig || [];
const entryIndex = formConfig.findIndex((f: any) => f.field === fieldDef.field);
const opt = (fieldDef.options || []).find((o: any) => o.value === value);
const opt = (fieldDef.options || []).find((o: any) => o.key === value || o.value === value);
const configDefs = opt?.config || [];
const oldExpand = entryIndex >= 0 ? formConfig[entryIndex]?.expand || [] : [];
const newExpand = configDefs.map((cd: any) => {
@@ -505,6 +619,51 @@ const updateSelectFieldValue = (fieldDef: any, value: any) => {
width: 100%;
}
.run-form-empty {
font-size: 12px;
color: #94a3b8;
padding: 8px 0;
line-height: 1.6;
}
.run-form-list {
.run-form-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 6px;
background: #f8fafc;
.run-form-label {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-form-node {
flex-shrink: 0;
font-size: 11px;
color: #64748b;
background: #eef2f7;
border-radius: 999px;
padding: 0 6px;
line-height: 1.6;
}
.run-form-type {
flex-shrink: 0;
}
}
}
:deep(.el-empty) {
padding: 32px 12px;
}
@@ -0,0 +1,93 @@
<template>
<div class="prompt-editor">
<el-button type="primary" plain style="width: 100%" @click="visible = true">
<el-icon><EditPen /></el-icon>
{{ buttonText }}
</el-button>
<div v-if="modelValue" class="pe-filled">
<span class="pe-filled-text" :title="modelValue">{{ modelValue }}</span>
<el-button size="small" text type="danger" @click="clear">清除</el-button>
</div>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="720px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-input v-model="draft" type="textarea" :rows="12" maxlength="5000" show-word-limit placeholder="请输入提示词内容" />
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { EditPen } from '@element-plus/icons-vue';
defineOptions({ name: 'PromptEditor' });
const props = withDefaults(
defineProps<{
modelValue?: string;
buttonText?: string;
dialogTitle?: string;
}>(),
{
modelValue: '',
buttonText: '编辑提示词',
dialogTitle: '编辑提示词',
}
);
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>();
const visible = ref(false);
const draft = ref('');
// 打开弹窗时同步当前值,关闭时丢弃未确认的草稿
watch(visible, (val) => {
if (val) draft.value = props.modelValue || '';
});
const handleConfirm = () => {
emit('update:modelValue', draft.value.trim());
visible.value = false;
};
const clear = () => {
emit('update:modelValue', '');
};
</script>
<style scoped lang="scss">
.prompt-editor {
width: 100%;
.pe-filled {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 4px;
padding: 4px 8px;
.pe-filled-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #0369a1;
}
}
}
</style>
@@ -0,0 +1,227 @@
// 模型参数表单共用工具(workflow 独立实现,不依赖内容创作)
// 与项目一致的深拷贝方式
export function deepClone<T>(val: T): T {
return JSON.parse(JSON.stringify(val ?? null)) as T;
}
// 按类型生成叶子字段空值
export function defaultLeafValue(def: any): any {
const t = def?.type || 'string';
const ft = def?.fieldType || '';
if (t === 'boolean' || ft === 'switch') return false;
if (t === 'number' || ft === 'number') return 0;
return '';
}
// 归一化数组元素:元素为 { type:'object', attrs } 包装时,递归预填 attrs 子字段
export function normalizeArrayItem(item: any): any {
if (item && typeof item === 'object') {
if (item.type === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
Object.values(item.attrs).forEach(normalizeFieldDef);
}
}
return item;
}
// 归一化字段定义:用 defaultValue 预填 value;递归处理 object/array 子树
export function normalizeFieldDef(def: any): any {
if (!def || typeof def !== 'object') return def;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
Object.values(attrs).forEach(normalizeFieldDef);
}
if (def.value === undefined || def.value === null) {
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : {};
}
} else if (t === 'array') {
if (Array.isArray(def.value)) {
def.value.forEach(normalizeArrayItem);
} else if (Array.isArray(def.defaultValue)) {
def.value = def.defaultValue.map((it: any) => normalizeArrayItem(deepClone(it)));
} else {
def.value = [];
}
} else {
if (def.value === undefined || def.value === null) {
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : defaultLeafValue(def);
}
}
return def;
}
// 归一化整个 requestBodyMapping 参数模板对象
export function normalizeModelParams(params: any): Record<string, any> {
if (!params || typeof params !== 'object') return {};
Object.values(params).forEach(normalizeFieldDef);
return params;
}
// 剔除 isForm === false(只读)字段:不在节点表单中显示,且不随工作流保存。
// 级联规则:容器若内部无可编辑子级则无意义,一并剔除
// 1. 字段自身 isForm === false → 整体剔除
// 2. object:剔除子字段后 attrs 为空(无可编辑子字段)→ object 一并剔除
// 3. array:过滤「子字段全部只读」的元素模板;无可编辑模板 → array 一并剔除
// 4. array 已添加元素内部被清空 → 一并移除
export function stripReadonlyFields(params: any): any {
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
// 自身标记为只读 → 整体删除
if (def.isForm === false) {
delete params[key];
continue;
}
if (def.type === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
stripReadonlyFields(attrs);
// 子字段全部只读 → object 无意义,一并剔除
if (Object.keys(attrs).length === 0) {
delete params[key];
}
}
} else if (def.type === 'array') {
let hasEditable = false;
// 单一元素模板(attrs 为 object
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) {
stripReadonlyFields(def.attrs);
if (Object.keys(def.attrs).length > 0) hasEditable = true;
}
// 多元素模板:优先 enumValues(与 ModelField.arrayTemplates 一致),其次 attrs 数组;过滤子字段全部只读的空模板
const isEnumValues = Array.isArray(def.enumValues) && def.enumValues.length > 0;
const isAttrsArray = Array.isArray(def.attrs) && def.attrs.length > 0;
const templates = isEnumValues ? def.enumValues : isAttrsArray ? def.attrs : [];
const keptTemplates = templates.filter((t: any) => {
if (t && typeof t === 'object' && t.attrs && typeof t.attrs === 'object' && !Array.isArray(t.attrs)) {
stripReadonlyFields(t.attrs);
return Object.keys(t.attrs).length > 0;
}
return true;
});
if (isEnumValues) def.enumValues = keptTemplates;
if (isAttrsArray) def.attrs = keptTemplates;
if (keptTemplates.length > 0) hasEditable = true;
// 已添加元素内部清理后为空的 → 一并移除
if (Array.isArray(def.value)) {
def.value.forEach((it: any) => {
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
stripReadonlyFields(it.attrs);
}
});
def.value = def.value.filter((it: any) => {
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
return Object.keys(it.attrs).length > 0;
}
return true;
});
}
// 无可编辑内容 → array 无意义,一并剔除
if (!hasEditable) {
delete params[key];
}
}
}
return params;
}
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
// 单个暴露叶子字段(路径带实例索引,如 "messages.value[0].attrs.content"
export interface ExposedField {
path: string;
label: string;
fieldType: string;
type: string;
required: boolean;
options?: any[];
value?: any;
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
}
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
// 路径文法(与 restoreRuntimeShow 共用):
// object 子字段 → 父路径 + ".attrs." + 子key
// array 实例元素 → 父路径 + ".value[i]"
// 原始值数组元素(非 object 包装)不可勾选,跳过
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
if (!params || typeof params !== 'object') return [];
const result: ExposedField[] = [];
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
const path = prefix ? `${prefix}.${key}` : key;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
result.push(...collectExposedFields(attrs, `${path}.attrs`));
}
} else if (t === 'array') {
// 仅遍历实例数组;元素为 { type:'object', attrs } 包装时才递归
if (Array.isArray(def.value)) {
def.value.forEach((item: any, i: number) => {
if (item && typeof item === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
result.push(...collectExposedFields(item.attrs, `${path}.value[${i}].attrs`));
}
});
}
} else if (def.runtimeShow === true) {
// 叶子且已勾选
result.push({
path,
label: def.label || key,
fieldType: def.fieldType || 'string',
type: t || 'string',
required: !!def.required,
options: Array.isArray(def.options) ? def.options : undefined,
value: def.value,
});
}
}
return result;
}
// 按暴露清单的 path 反解,把对应叶子 def 的 runtimeShow 置 true(加载时还原勾选)
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined): void {
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
for (const f of fields) {
if (!f || typeof f.path !== 'string') continue;
const cur = resolvePath(params, f.path);
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
cur.runtimeShow = true;
}
}
}
// 按路径走回 params 树;解析失败(schema 变更导致字段缺失)返回 undefined
function resolvePath(params: any, path: string): any {
if (!path) return undefined;
const segments = path.split('.');
let cur = params;
for (const seg of segments) {
if (cur === undefined || cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
if (seg === 'attrs') {
cur = cur.attrs;
if (!cur || typeof cur !== 'object') return undefined;
} else {
const m = seg.match(/^value\[(\d+)\]$/);
if (m) {
const idx = Number(m[1]);
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
cur = cur.value[idx];
} else {
cur = cur[seg];
}
}
}
return cur;
}
// 深度比较(JSON 序列化方式),供 index.vue 的"仅变更才写"守卫使用
export function isEqual(a: any, b: any): boolean {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
+232 -12
View File
@@ -19,6 +19,7 @@
<NodeConfigPanel
:selected-node="selectedNode"
:node-config="currentNodeConfig"
:upstream-nodes="upstreamNodes"
@update:selected-node="updateSelectedNode"
@open-model-selector="showModelSelector = true"
@remove-model="handleRemoveModel"
@@ -82,7 +83,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, watch, onMounted, markRaw } from 'vue';
import { VueFlow, useVueFlow } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
@@ -98,6 +99,13 @@ import {
} from '/@/api/settings/creation';
import { getNodeLibraryList, type NodeLibraryGroup } from '/@/api/settings/workflow';
import NodeConfigPanel from './component/NodeConfigPanel.vue';
import {
stripReadonlyFields,
collectExposedFields,
restoreRuntimeShow,
isEqual,
type ExposedField,
} from './component/modelParamUtils';
import NodeLibraryPanel from './component/NodeLibraryPanel.vue';
import WorkflowListPanel from './component/WorkflowListPanel.vue';
import SaveWorkflowDialog from './component/SaveWorkflowDialog.vue';
@@ -105,26 +113,56 @@ import ModelSelector from './component/ModelSelector.vue';
import SkillSelector from './component/SkillSelector.vue';
import FlowNode from './component/FlowNode.vue';
// 开始节点「运行表单字段」:在暴露清单基础上标注来源节点
interface RunFormField extends ExposedField {
nodeId: string;
nodeLabel: string;
}
// 前驱节点的可引用输出字段(供模型参数「引用上级节点输出」)
interface UpstreamField {
field: string;
label: string;
}
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: UpstreamField[];
}
interface NodeData {
label?: string;
nodeCode?: string;
desc?: string;
formConfig?: any[];
modelConfig?: { modelId?: string; modelName?: string; modelType?: string | number; modelRequestParams?: any } | null;
modelConfig?: {
modelId?: string;
modelName?: string;
modelType?: string | number;
modelRequestParams?: any;
modelFormFields?: ExposedField[] | null;
modelResponseBodyMapping?: any; // 模型返回参数(数组或对象两种结构),供下游引用
} | null;
skillName?: string;
prompt?: string;
negativePrompt?: string;
patchLayout?: boolean;
isSaveFile?: boolean;
preTool?: string | null;
runFormFields?: RunFormField[]; // 仅开始节点使用
}
const { addNodes, addEdges, findNode, removeNodes, getNodes, updateNode } = useVueFlow();
// 常量定义
const START_NODE_CODE = '__start__';
// 有输出参数、可作为下游引用来源的节点类型(模型/HTTP/表单)
const OUTPUT_NODE_CODES = ['model', 'http', 'form'];
const JUDGE_KEYWORDS = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'];
// 自定义节点类型:默认节点内置 DefaultNode 只有上下两个 Handle,自定义组件支持左右连接
const nodeTypes = { default: FlowNode, input: FlowNode };
const nodeTypes = { default: markRaw(FlowNode), input: markRaw(FlowNode) };
// 节点库相关状态
const nodeLibraryGroups = ref<NodeLibraryGroup[]>([]);
@@ -140,6 +178,7 @@ const nodeConfigMap = computed(() => {
formConfigOption: boolean;
skillOption: boolean;
promptOption: boolean;
negativePromptOption: boolean;
isSaveFileOption: boolean;
}
>();
@@ -151,6 +190,7 @@ const nodeConfigMap = computed(() => {
formConfigOption: item.formConfigOption || false,
skillOption: item.skillOption || false,
promptOption: item.promptOption || false,
negativePromptOption: item.negativePromptOption || false,
isSaveFileOption: item.isSaveFileOption || false,
});
});
@@ -277,7 +317,11 @@ const handleModelConfirm = (model: any) => {
modelId: model.id || '',
modelName: model.modelName,
modelType: model.modelType,
modelRequestParams: null,
// 深拷贝模型 requestBodyMapping 作为参数模板(重选模型时覆盖旧参数)
// 剔除 isForm=false 的只读字段,使其不显示也不随工作流保存
modelRequestParams: stripReadonlyFields(JSON.parse(JSON.stringify(model.requestBodyMapping ?? null))),
// 保存模型返回参数(responseBodyMapping),作为该节点可被下游引用的输出项
modelResponseBodyMapping: model.responseBodyMapping ?? null,
},
},
};
@@ -396,6 +440,158 @@ const isStartNode = (node: Node<NodeData, any, string>) => {
return node.data?.nodeCode === START_NODE_CODE;
};
// 聚合所有非开始节点勾选的「表单展示」字段,同步到开始节点的运行表单字段
const syncRunFormFields = () => {
const startNode = nodes.value.find((n) => isStartNode(n));
if (!startNode?.data) return;
const collected: RunFormField[] = [];
for (const n of nodes.value) {
if (isStartNode(n)) continue;
const params = n.data?.modelConfig?.modelRequestParams;
if (!params || typeof params !== 'object') continue;
for (const f of collectExposedFields(params)) {
collected.push({
...f,
nodeId: n.id,
nodeLabel: n.data?.label || n.id,
});
}
}
// 仅变更才写回,避免深监听死循环
if (isEqual(collected, startNode.data.runFormFields)) return;
startNode.data.runFormFields = collected;
updateNode(startNode.id, startNode);
const index = nodes.value.findIndex((n) => n.id === startNode.id);
if (index >= 0) {
nodes.value[index] = startNode;
}
};
// 节点变化(勾选/取消/删除/换模型)→ 自动重算开始节点运行表单字段
watch(
() => nodes.value,
() => {
syncRunFormFields();
},
{ deep: true }
);
// 解析 schemaJson 值为 JSON 对象(兼容字符串/对象两种存储)
const parseSchema = (value: any): any => {
if (!value) return null;
if (typeof value === 'object' && !Array.isArray(value)) return value;
if (typeof value === 'string') {
const t = value.trim();
if (!t) return null;
try {
return JSON.parse(t);
} catch {
return null;
}
}
return null;
};
// 递归收集 JSON schema 的所有叶子字段(数组取首元素对象作为结构样本)
const collectLeafFieldsFromJson = (obj: any, prefix = ''): UpstreamField[] => {
const result: UpstreamField[] = [];
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return result;
for (const key of Object.keys(obj)) {
if (!key || key.startsWith('_temp_')) continue;
const path = prefix ? `${prefix}.${key}` : key;
const val = obj[key];
if (val && typeof val === 'object') {
if (Array.isArray(val)) {
const sample = val.find((it: any) => it && typeof it === 'object' && !Array.isArray(it));
if (sample) result.push(...collectLeafFieldsFromJson(sample, path));
else result.push({ field: path, label: path });
} else {
result.push(...collectLeafFieldsFromJson(val, path));
}
} else {
result.push({ field: path, label: path });
}
}
return result;
};
// 取节点在设计时的可引用输出字段(运行时这些字段会产出实际值)
const getNodeOutputFields = (node: Node<NodeData, any, string>): UpstreamField[] => {
const nodeCode = node.data?.nodeCode || '';
if (nodeCode === 'form') {
// form 节点:自定义表单字段即输出
return (node.data?.formConfig || []).map((f: any) => ({
field: f.field || f.label || '',
label: f.label || f.field || '',
}));
}
if (nodeCode === 'http') {
// http 节点:输出 = 结果返回结构(response schema)的所有叶子字段。
// 结果返回方式为主动拉取(responseType === 'pull')时,取主动拉取分支下配置的结果返回结构。
const formConfig = node.data?.formConfig || [];
const responseTypeEntry = formConfig.find((f: any) => f.field === 'responseType');
const isPull = !!responseTypeEntry && String(responseTypeEntry.value || '') === 'pull';
const responseField = isPull
? (responseTypeEntry?.expand || []).find((e: any) => e.field === 'response')
: formConfig.find((f: any) => f.field === 'response');
const schema = parseSchema(responseField?.value);
if (schema) return collectLeafFieldsFromJson(schema);
return [];
}
if (nodeCode === 'model') {
// 模型节点:模型的返回参数(responseBodyMapping)即可被下游引用的输出
const resp = node.data?.modelConfig?.modelResponseBodyMapping;
const keys = Array.isArray(resp) ? resp : resp && typeof resp === 'object' ? Object.keys(resp) : [];
return keys
.filter((k: any) => typeof k === 'string' && k.trim() !== '')
.map((k: string) => ({ field: k, label: k }));
}
if (nodeCode === START_NODE_CODE) {
// 开始节点:运行表单字段(被勾选的模型参数)
return (node.data?.runFormFields || []).map((f: any) => ({ field: f.path || '', label: f.label || f.path || '' }));
}
return [];
};
// 当前选中节点的所有前驱链路节点(含各自可引用输出字段)
const upstreamNodes = computed<UpstreamNodeInfo[]>(() => {
const nodeId = selectedNode.value?.id;
if (!nodeId) return [];
const predsByTarget = new Map<string, string[]>();
edges.value.forEach((e) => {
if (!predsByTarget.has(e.target)) predsByTarget.set(e.target, []);
predsByTarget.get(e.target)!.push(e.source);
});
const result: UpstreamNodeInfo[] = [];
const visited = new Set<string>([nodeId]);
const queue: string[] = [nodeId];
while (queue.length) {
const cur = queue.shift()!;
for (const p of predsByTarget.get(cur) || []) {
if (visited.has(p)) continue;
visited.add(p);
const n = nodes.value.find((x) => x.id === p);
if (n) {
// 只有模型/HTTP/表单节点有输出参数,可作为下游引用来源
if (OUTPUT_NODE_CODES.includes(n.data?.nodeCode || '')) {
result.push({
id: n.id,
label: n.data?.label || n.id,
nodeCode: n.data?.nodeCode || '',
outputFields: getNodeOutputFields(n),
});
}
}
queue.push(p);
}
}
return result;
});
// 辅助函数:判断是否为判断节点
const isJudgeNode = (node: Node<NodeData, any, string>) => {
const nodeCode = (node.data?.nodeCode || '').toLowerCase();
@@ -505,7 +701,7 @@ const addDefaultStartNode = () => {
id: 'start-node',
type: 'input',
position: { x: 200, y: 200 },
data: { label: '开始', nodeCode: '__start__' },
data: { label: '开始', nodeCode: '__start__', runFormFields: [] },
};
addNodes([startNode]);
nodes.value.push(startNode);
@@ -587,7 +783,7 @@ const buildNodeFormConfigFromDsl = (n: any) => {
if (def.field === 'responseType') {
// 恢复嵌套配置:定义取自 presetOption 选中选项的 config,值取自 options[0].config
const savedConfig = out?.options?.[0]?.config || [];
const selectedOpt = (def.options || []).find((o: any) => o.value === out?.value);
const selectedOpt = (def.options || []).find((o: any) => o.key === out?.value || o.value === out?.value);
const expandDefs = selectedOpt?.config || [];
const expand = expandDefs.map((cd: any) => {
const saved = savedConfig.find((c: any) => c.field === cd.field);
@@ -650,6 +846,23 @@ const buildOutputConfig = (node: Node<NodeData>) => {
return null;
};
// 从 DSL 构建模型配置:剔除只读字段 + 用 modelFormFields 还原勾选(幂等,兼容旧 DSL)
const buildModelConfigFromDsl = (n: any) => {
const modelRequestParams = stripReadonlyFields(n.modelConfig?.modelRequestParams ?? null);
const modelFormFields = n.modelConfig?.modelFormFields ?? null;
if (modelRequestParams && typeof modelRequestParams === 'object' && Array.isArray(modelFormFields)) {
restoreRuntimeShow(modelRequestParams, modelFormFields);
}
return {
modelId: n.modelConfig?.modelId || '',
modelName: '',
modelType: undefined,
modelRequestParams,
modelFormFields,
modelResponseBodyMapping: n.modelConfig?.modelResponseBodyMapping ?? null,
};
};
// 从 DSL 加载工作流
const loadWorkflowFromDsl = (dsl: any) => {
if (!dsl) return;
@@ -666,16 +879,14 @@ const loadWorkflowFromDsl = (dsl: any) => {
nodeCode: n.nodeCode,
desc: n.desc || '',
formConfig: buildNodeFormConfigFromDsl(n),
modelConfig: {
modelId: n.modelConfig?.modelId || '',
modelName: '',
modelType: undefined,
modelRequestParams: n.modelConfig?.modelRequestParams ?? null,
},
modelConfig: buildModelConfigFromDsl(n),
skillName: n.skillName || null,
prompt: n.prompt || '',
negativePrompt: n.negativePrompt || '',
patchLayout: n.patchLayout || false,
isSaveFile: Boolean(n.isSaveFile),
preTool: n.preTool ?? null,
...(isStart ? { runFormFields: Array.isArray(n.runFormFields) ? n.runFormFields : [] } : {}),
},
};
});
@@ -755,10 +966,19 @@ const confirmSaveWorkflow = async () => {
modelConfig: {
modelId: n.data?.modelConfig?.modelId || '',
modelRequestParams: n.data?.modelConfig?.modelRequestParams ?? null,
// 保存时实时收集勾选的「表单展示」字段(路径带实例索引)
...(n.data?.modelConfig?.modelRequestParams
? { modelFormFields: collectExposedFields(n.data.modelConfig.modelRequestParams) }
: {}),
// 模型返回参数随工作流保存,保证重开后仍可被下游引用
modelResponseBodyMapping: n.data?.modelConfig?.modelResponseBodyMapping ?? null,
},
outputConfig: buildOutputConfig(n),
...(n.data?.skillName ? { skillName: n.data.skillName } : {}),
...(n.data?.prompt ? { prompt: n.data.prompt } : {}),
...(n.data?.negativePrompt ? { negativePrompt: n.data.negativePrompt } : {}),
...(n.data?.patchLayout ? { patchLayout: n.data.patchLayout } : {}),
...(isStartNode(n) ? { runFormFields: n.data?.runFormFields ?? [] } : {}),
outputResult: null,
};
}),