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:
@@ -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>
|
||||
|
||||
@@ -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 用 defaultValue,role 从 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user