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
+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,
};
}),