From 41655fa8256ed59a7abf9fec046fef80e535262a Mon Sep 17 00:00:00 2001
From: 2910410219 <2910410219@qq.com>
Date: Sat, 15 Aug 2026 18:49:56 +0800
Subject: [PATCH] =?UTF-8?q?sub=5Fflow=20=E8=8A=82=E7=82=B9=E5=BC=95?=
=?UTF-8?q?=E5=85=A5=E5=B7=A5=E4=BD=9C=E6=B5=81=E4=BF=AE=E5=A4=8D=E4=B8=8E?=
=?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=89=A7=E8=A1=8C=E5=B7=A5=E4=BD=9C=E6=B5=81?=
=?UTF-8?q?=E9=87=8D=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
---
src/api/settings/workflow/index.ts | 38 ++
src/views/home/components/InputBar.vue | 14 +-
src/views/home/components/MainContent.vue | 71 ++-
.../home/components/SessionModelSetter.vue | 478 ++++++++++++++++++
src/views/home/index.vue | 97 +++-
src/views/home/utils/flowDsl.ts | 298 +++--------
src/views/home/utils/wsExecute.ts | 16 +-
.../workflow/component/ModelField.vue | 96 ++--
.../workflow/component/NodeConfigPanel.vue | 49 ++
.../workflow/component/SubFlowParams.vue | 53 ++
.../workflow/component/WorkflowSelector.vue | 232 +++++++++
.../workflow/component/modelParamUtils.ts | 84 ++-
.../workflow/component/subFlowTypes.ts | 58 +++
src/views/settings/workflow/index.vue | 392 +++++++++++++-
14 files changed, 1620 insertions(+), 356 deletions(-)
create mode 100644 src/views/home/components/SessionModelSetter.vue
create mode 100644 src/views/settings/workflow/component/SubFlowParams.vue
create mode 100644 src/views/settings/workflow/component/WorkflowSelector.vue
create mode 100644 src/views/settings/workflow/component/subFlowTypes.ts
diff --git a/src/api/settings/workflow/index.ts b/src/api/settings/workflow/index.ts
index 98b8908..a2f7d00 100644
--- a/src/api/settings/workflow/index.ts
+++ b/src/api/settings/workflow/index.ts
@@ -12,6 +12,10 @@ export interface NodeLibraryPresetOption {
value: string;
config?: NodeLibraryPresetOption[] | null;
}> | null;
+ // 运行时已在用的扩展字段(补齐声明消除 TS 报错)
+ isFormField?: boolean;
+ constraint?: any;
+ defaultValue?: any;
}
export interface NodeLibraryItem {
@@ -89,3 +93,37 @@ export function getWorkflowSkillList(params?: { pageNum: number; pageSize: numbe
params,
}) as Promise<{ code: number; message: string; data: { list: WorkflowSkillItem[]; total: number } }>;
}
+
+// ===== 子流程:引入已有工作流(独立实现,不依赖 creation)=====
+export interface SubFlowWorkflowItem {
+ id: string;
+ // 后端列表接口返回 flowName(name 仅为前端兜底)
+ name?: string;
+ flowName?: string;
+ description?: string;
+ [key: string]: any;
+}
+
+// 获取"我的工作流"列表(IsOwn=true 只取本人创建,供 sub_flow 节点选择引入)
+// 响应结构多形态容错在调用方处理(data.list ?? data.listFlowUserRes.list)
+export function getSubFlowWorkflowList(params?: {
+ pageNum: number;
+ pageSize: number;
+ keyword?: string;
+ IsOwn?: boolean;
+}) {
+ return request({
+ url: '/ai-agent/flow/user/list',
+ method: 'get',
+ params,
+ }) as Promise<{ code: number; message: string; data: any }>;
+}
+
+// 获取工作流详情(取开始节点 outputConfig 构建 sub_flow 引入参数)
+export function getSubFlowWorkflowDetail(id: string) {
+ return request({
+ url: '/ai-agent/flow/user/get',
+ method: 'get',
+ params: { id },
+ }) as Promise<{ code: number; message: string; data: any }>;
+}
diff --git a/src/views/home/components/InputBar.vue b/src/views/home/components/InputBar.vue
index a2c5815..60a3155 100644
--- a/src/views/home/components/InputBar.vue
+++ b/src/views/home/components/InputBar.vue
@@ -37,7 +37,13 @@
技能
-
+
+
+
+
Shift+Enter 换行
@@ -75,7 +81,7 @@
diff --git a/src/views/home/index.vue b/src/views/home/index.vue
index 63cc635..4292001 100644
--- a/src/views/home/index.vue
+++ b/src/views/home/index.vue
@@ -37,9 +37,11 @@
:workflow-locked="isHistoryWorkflow"
:hide-shortcuts="isPlaceholder"
:generating="isGenerating"
+ :current-model-name="currentChatModelName"
@send="handleSend"
@workflow-select="handleWorkflowSelect"
@stop="handleStopGenerate"
+ @select-model="sessionModelDialogVisible = true"
/>
@@ -65,6 +67,14 @@
:template="pendingTemplate"
@saved="handleTemplateSaved"
/>
+
+
+
@@ -75,8 +85,9 @@ import Sidebar from './components/Sidebar.vue';
import MainContent from './components/MainContent.vue';
import InputBar from './components/InputBar.vue';
import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue';
+import SessionModelSetter from './components/SessionModelSetter.vue';
import { applyHomeFormValues } from './utils/flowDsl';
-import { getChatModel } from '/@/api/settings/modelConfigV2';
+import { getChatModel, listModelManage } from '/@/api/settings/modelConfigV2';
import { connectSessionSocket, sendAgentStart, sendWorkflowStart, sendCancel } from './utils/wsExecute';
import { parseWsMessage, getDelta, getAnswer, getErrorText, getToolCallName, getToolResultText } from './utils/wsMessage';
import type { ExecutionTreeItem } from '/@/api/settings/creation';
@@ -304,6 +315,48 @@ const getList = async () => {
}
};
+// ===== 会话模型:展示当前会话模型名称 =====
+const loadCurrentChatModel = async () => {
+ try {
+ const res: any = await getChatModel();
+ const mm = res?.data?.modelManage || res?.data || {};
+ const id = mm.id ?? mm.modelId;
+ currentChatModelId.value = id ?? null;
+ currentChatModelName.value = mm.modelName || '';
+ // id 有而名称为空时,按 id 从模型列表匹配名称
+ if (id && !currentChatModelName.value) {
+ const listRes: any = await listModelManage({ pageNum: 1, pageSize: 100 });
+ const found = (listRes?.data?.list || []).find((m: any) => String(m.id) === String(id));
+ currentChatModelName.value = found?.modelName || '';
+ }
+ } catch {
+ currentChatModelId.value = null;
+ currentChatModelName.value = '';
+ }
+};
+
+// 会话模型设置成功:更新展示,若有被拦截的消息则自动重发
+const handleSessionModelSaved = async (model: { id: string; modelName: string }) => {
+ currentChatModelId.value = model.id;
+ currentChatModelName.value = model.modelName;
+ sessionModelDialogVisible.value = false;
+ const retry = pendingRetry.value;
+ pendingRetry.value = null;
+ if (!retry) return;
+ const { sid, sessionId, message } = retry;
+ const session = historyList.value.find((h) => h.id === sid);
+ if (!session || sendingSessions[sid]) return;
+ sendingSessions[sid] = true;
+ session.status = 'executing';
+ try {
+ await runChat(sid, sessionId, message);
+ } catch {
+ session.status = 'failed';
+ } finally {
+ delete sendingSessions[sid];
+ }
+};
+
const selectedWorkflowDetail = ref(null);
const mainContentRef = ref(null);
const sendingSessions = reactive>({});
@@ -328,6 +381,13 @@ const inputBarRef = ref(null);
const templateDialogVisible = ref(false);
const pendingTemplate = ref(null);
+// 会话模型设置弹窗
+const sessionModelDialogVisible = ref(false);
+const currentChatModelId = ref(null);
+const currentChatModelName = ref('');
+// 发送前被拦截时记录的待重发消息
+const pendingRetry = ref<{ sid: string; sessionId: string; message: string } | null>(null);
+
const isSendDisabled = computed(() => {
if (!activeHistoryId.value) return false;
if (sendingSessions[activeHistoryId.value]) return true;
@@ -574,7 +634,7 @@ const handleSend = async (message: string) => {
// 分支:有工作流 → 执行工作流;无工作流 → 普通对话
if (selectedWorkflowDetail.value) {
- await runWorkflow(sid, sessionId, message, mc);
+ await runWorkflow(sid, sessionId, mc);
} else {
await runChat(sid, sessionId, message);
}
@@ -678,7 +738,7 @@ const handleStopGenerate = () => {
};
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
-const runWorkflow = async (sid: string, sessionId: string, message: string, mc: any) => {
+const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
const curSession = historyList.value.find((h) => h.id === sid);
if (!curSession) {
delete sendingSessions[sid];
@@ -744,9 +804,20 @@ const runWorkflow = async (sid: string, sessionId: string, message: string, mc:
};
try {
- // 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回对应字段(model → modelRequestParams;form → outputConfig)
+ // 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回开始节点(唯一表单源)运行字段
const nodeInputParams = JSON.parse(JSON.stringify(selectedWorkflowDetail.value.nodeInputParams || []));
- applyHomeFormValues(nodeInputParams, mc.formValues);
+ applyHomeFormValues(nodeInputParams, mc.formValues, mc.formFileNames);
+ // 开始节点为唯一表单源:执行时 model 节点不带参数结构、form 节点不带自定义字段
+ // (值已汇总进开始节点 outputConfig 一并提交,避免重复/冗余参数)
+ nodeInputParams.forEach((n: any) => {
+ const code = String(n?.nodeCode || '').toLowerCase();
+ if (code === 'model' && n?.modelConfig && typeof n.modelConfig === 'object') {
+ delete n.modelConfig.modelRequestParams;
+ delete n.modelConfig.modelFormFields;
+ } else if (code === 'form') {
+ delete n.outputConfig;
+ }
+ });
// 2. 构建 flowContent
const updatedFlowContent = {
@@ -802,8 +873,6 @@ const runWorkflow = async (sid: string, sessionId: string, message: string, mc:
sendWorkflowStart(ws, {
flowId: selectedWorkflowDetail.value.id,
flowContent: updatedFlowContent,
- systemPrompt: '',
- question: message || '执行工作流',
});
} catch (e: any) {
finishExec(false, e?.message || '执行失败,请重试');
@@ -947,6 +1016,19 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
chatModelId = undefined;
}
+ // 无会话模型:拦截本轮,弹窗引导设置,记录待重发消息(移除刚插入的 loading 气泡并释放发送状态)
+ if (!chatModelId) {
+ sessionModelDialogVisible.value = true;
+ pendingRetry.value = { sid, sessionId, message };
+ const list = sessionMessages.value.get(sid);
+ const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
+ if (list && aiIdx >= 0) list.splice(aiIdx, 1);
+ delete sendingSessions[sid];
+ const cur = historyList.value.find((h) => h.id === sid);
+ if (cur) cur.status = undefined;
+ return;
+ }
+
// 会话级长连接:本轮消息处理整体进 handler,连接只路由到 activeHandler(打字机/thinking 闭包保留在上面)
const handler: RoundHandler = {
onMessage: (raw) => {
@@ -1244,6 +1326,7 @@ const handleDeleteHistory = async (id: string) => {
onMounted(() => {
getList();
+ loadCurrentChatModel();
// 不自动创建 / 选中会话,默认显示占位引导页
});
diff --git a/src/views/home/utils/flowDsl.ts b/src/views/home/utils/flowDsl.ts
index 351c511..7ef83cc 100644
--- a/src/views/home/utils/flowDsl.ts
+++ b/src/views/home/utils/flowDsl.ts
@@ -1,6 +1,6 @@
// ===== 首页执行工作流管理 DSL 的解析与写回工具 =====
// 独立实现,不依赖 settings/workflow 或 settings/creation 的旧结构。
-// 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .value[i]
+// 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .enumValues[i]
export interface HomeFormField {
path: string;
@@ -12,6 +12,12 @@ export interface HomeFormField {
default?: any;
options?: any[];
fieldConstraint?: any;
+ // 引用上游节点输出({ nodeId, field });有则首页只读展示引用,值由上游节点提供
+ valueSource?: any;
+ // 来源节点 id(未配置 valueSource 时默认引用开始节点,用于引用展示)
+ nodeId?: string;
+ // 上传字段的文件名(单文件为字符串,多文件为数组),执行时随 value 一并写回传给后端
+ fileName?: string | string[];
// 旧兼容:creation 时代 http body 中 showInForm 的子字段
__isHttpBodyChild?: boolean;
bodyKey?: string;
@@ -21,41 +27,16 @@ export function deepClone(val: T): T {
return JSON.parse(JSON.stringify(val ?? null)) as T;
}
-// 按路径走回对象;解析失败(schema 变更导致字段缺失)返回 undefined
-export 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;
-}
-
-// 字段类型映射:modelRequestParams def → MainContent 控件 type
-function mapFieldType(def: any): string {
- const ft = def.fieldType || '';
- const t = def.type || 'string';
+// 开始节点运行字段(runFormFields)→ MainContent 控件 type
+function mapRunFieldType(f: any): string {
+ const ft = String(f?.fieldType || '');
+ const t = String(f?.type || 'string');
if (ft === 'number' || t === 'number') return 'number';
if (ft === 'boolean' || t === 'boolean' || ft === 'switch') return 'switch';
- if (ft === 'select') return 'select';
+ if (ft === 'select' && Array.isArray(f?.options)) return 'select';
if (ft === 'textarea') return 'textarea';
- if (ft === 'upload' || ft === 'file' || ft === 'fileUpload') {
- return ft === 'uploadMultiple' || def.multiple ? 'uploadMultiple' : 'upload';
- }
+ if (ft === 'uploadMultiple' || t === 'uploadMultiple' || f?.multiple) return 'uploadMultiple';
+ if (ft === 'upload' || ft === 'file' || ft === 'fileUpload' || t === 'upload' || t === 'file') return 'upload';
return 'input';
}
@@ -77,211 +58,76 @@ function normalizeOptions(def: any): any[] | undefined {
});
}
-// select 字段当前值:兼容 def.value 为 { value, options } 对象的特殊结构
-function leafValue(def: any): any {
- const v = def?.value;
- if (v && typeof v === 'object' && !Array.isArray(v) && 'value' in v) return v.value;
- return v;
-}
-
-// model 节点:遍历 modelRequestParams 收集 runtimeShow === true 的叶子字段
-function collectModelFields(node: any): HomeFormField[] {
- const params = node?.modelConfig?.modelRequestParams;
- if (!params || typeof params !== 'object' || Array.isArray(params)) return [];
- const fields: HomeFormField[] = [];
- const walk = (def: any, prefix: string) => {
- for (const key of Object.keys(def)) {
- const f = def[key];
- if (!f || typeof f !== 'object') continue;
- const path = prefix ? `${prefix}.${key}` : key;
- const t = f.type;
- if (t === 'object') {
- if (f.attrs && typeof f.attrs === 'object' && !Array.isArray(f.attrs)) walk(f.attrs, `${path}.attrs`);
- } else if (t === 'array') {
- // 仅遍历已有实例;元素为 { type:'object', attrs } 包装时才递归
- if (Array.isArray(f.value)) {
- f.value.forEach((item: any, i: number) => {
- if (item && typeof item === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
- walk(item.attrs, `${path}.value[${i}].attrs`);
- }
- });
- }
- } else if (f.runtimeShow === true) {
- fields.push({
- path,
- label: f.label || key,
- type: mapFieldType(f),
- fieldType: f.fieldType || 'string',
- required: !!f.required,
- value: leafValue(f),
- default: f.defaultValue,
- options: normalizeOptions(f),
- fieldConstraint: f.fieldConstraint && typeof f.fieldConstraint === 'object' ? f.fieldConstraint : undefined,
- });
- }
- }
- };
- walk(params, '');
- return fields;
-}
-
-// form 节点:outputConfig 为用户自定义运行字段
-function collectFormNodeFields(node: any): HomeFormField[] {
+// 开始节点(__start__):读取 outputConfig 中保存的 runFormFields(工作流管理汇总的
+// model 勾选字段 + form 自定义字段),转首页扁平表单字段。条目已有
+// field/label/type/fieldType/required/value/defaultValue/fieldConstraint/valueSource/multiple,
+// 直接归一化映射,无需再走 model/form 各自结构。
+function collectStartNodeFields(node: any): HomeFormField[] {
const out = Array.isArray(node?.outputConfig) ? node.outputConfig : [];
return out
- .filter((o: any) => o && typeof o === 'object' && o.field !== undefined)
- .map((o: any) => ({
- path: o.field,
- label: o.label || o.field,
- type: o.type || 'input',
- fieldType: o.type || 'string',
- required: Boolean(o.required),
- value: o.value,
- default: o.value,
- options: Array.isArray(o.options) && o.options.length > 0 ? o.options : undefined,
- fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
- }));
-}
-
-// 旧兼容:creation 时代 formConfig(http 仅 body 中 showInForm 的子字段)
-function collectLegacyFormConfig(node: any): HomeFormField[] {
- const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
- const result: HomeFormField[] = [];
- fields.forEach((field: any) => {
- if (!field) return;
- if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
-
- if (String(node?.nodeCode || '').toLowerCase() === 'http') {
- if (field.field !== 'body') return;
- const bodyVal = field.value;
- if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
- Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
- if (!bodyItem || bodyItem.showInForm !== true) return;
- result.push({
- __isHttpBodyChild: true,
- bodyKey,
- path: `body.${bodyKey}`,
- label: bodyItem.key || bodyKey,
- type: bodyItem.fieldType || 'input',
- fieldType: bodyItem.fieldType || 'string',
- required: false,
- value: bodyItem.value,
- default: bodyItem.value,
- fieldConstraint: bodyItem.fieldConstraint && typeof bodyItem.fieldConstraint === 'object' ? bodyItem.fieldConstraint : undefined,
- });
- });
- return;
- }
-
- result.push({
- path: field.field || field.label,
- label: field.label || field.field,
- type: field.type || 'input',
- fieldType: field.fieldType || field.type || 'string',
- required: Boolean(field.required),
- value: field.value,
- default: field.default,
- options: Array.isArray(field.options) ? field.options : undefined,
- fieldConstraint: field.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : undefined,
+ .filter((o: any) => o && typeof o === 'object' && (o.field !== undefined || o.path !== undefined))
+ .map((o: any) => {
+ const field = o.field !== undefined ? o.field : o.path;
+ return {
+ path: field,
+ label: o.label || String(field),
+ type: mapRunFieldType(o),
+ fieldType: o.fieldType || o.type || 'string',
+ required: Boolean(o.required),
+ value: o.value !== undefined ? o.value : o.defaultValue,
+ default: o.defaultValue,
+ options: normalizeOptions(o),
+ fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
+ valueSource: o.valueSource && typeof o.valueSource === 'object' ? o.valueSource : undefined,
+ nodeId: o.nodeId,
+ fileName: o.fileName,
+ };
});
- });
- return result;
}
-// 主入口:DSL 节点 → 首页扁平表单字段
-// model → modelRequestParams runtimeShow 叶子;form → outputConfig;其余 → 旧 formConfig 兜底
+// 主入口:DSL 节点 → 首页扁平表单字段。开始节点为唯一表单源
+// (runFormFields 汇总了各 model 节点勾选字段 + form 节点自定义字段),其余节点无运行时字段。
export function collectHomeFormFields(node: any): HomeFormField[] {
- const code = String(node?.nodeCode || '').toLowerCase();
- let dslFields: HomeFormField[] = [];
- if (code === 'model') dslFields = collectModelFields(node);
- else if (code === 'form') dslFields = collectFormNodeFields(node);
- // http 等其它节点:新 DSL 无运行时字段(outputConfig 为请求配置),交给旧 formConfig 兜底
- if (dslFields.length > 0) return dslFields;
- return collectLegacyFormConfig(node);
+ if (String(node?.nodeCode || '').toLowerCase() !== '__start__') return [];
+ return collectStartNodeFields(node);
}
-// 上传类型字段判定(与 MainContent isFileField 一致)
-const isUploadType = (f: HomeFormField): boolean =>
- f.type === 'upload' || f.type === 'uploadMultiple' || f.type === 'fileUpload';
-
// 上传类型空值:未选文件(空字符串 / 空数组 / null)
const isEmptyUploadValue = (val: any): boolean => val === '' || val === null || (Array.isArray(val) && val.length === 0);
-// 按路径定位父级后删除末段字段(modelRequestParams 字段 / outputConfig 条目 / http body 子字段)
-function deleteResolvedPath(params: any, path: string): boolean {
- if (!path) return false;
- const segments = path.split('.');
- const last = segments[segments.length - 1];
- let cur = params;
- for (const seg of segments.slice(0, -1)) {
- if (cur === undefined || cur === null || typeof cur !== 'object') return false;
- if (seg === 'attrs') {
- cur = cur.attrs;
- if (!cur || typeof cur !== 'object') return false;
- } else {
- const m = seg.match(/^value\[(\d+)\]$/);
- if (m) {
- if (!Array.isArray(cur.value)) return false;
- cur = cur.value[Number(m[1])];
- } else {
- cur = cur[seg];
- }
- }
- }
- if (!cur || typeof cur !== 'object') return false;
- const lm = last.match(/^value\[(\d+)\]$/);
- if (lm) {
- if (!Array.isArray(cur.value)) return false;
- cur.value.splice(Number(lm[1]), 1);
- } else {
- delete cur[last];
- }
- return true;
-}
-
-// 从 DSL 移除空上传字段(对应 applyHomeFormValues 中的写回分支)
-function removeUploadField(node: any, code: string, f: HomeFormField): void {
- if (code === 'model') {
- deleteResolvedPath(node?.modelConfig?.modelRequestParams, f.path);
- } else if (code === 'form') {
- const list = Array.isArray(node?.outputConfig) ? node.outputConfig : [];
- const idx = list.findIndex((o: any) => o && o.field === f.path);
- if (idx >= 0) list.splice(idx, 1);
- } else if (f.__isHttpBodyChild && f.bodyKey && Array.isArray(node?.formConfig)) {
- const bodyField = node.formConfig.find((x: any) => x && x.field === 'body');
- if (bodyField?.value && typeof bodyField.value === 'object' && !Array.isArray(bodyField.value)) {
- delete bodyField.value[f.bodyKey];
- }
- }
-}
-
-// 首页表单值写回 DSL 对应字段(model → modelRequestParams.path.value;form → outputConfig[].value)
-export function applyHomeFormValues(nodes: any[], formValues: Record): void {
+// 首页表单值写回开始节点(唯一表单源)outputConfig 对应条目 value:
+// 1. 引用其他节点输出(valueSource)的字段只读展示,值由上游节点运行时提供,
+// 清空保存时的值快照(避免后端误用过期值)
+// 2. 上传类型字段未选文件 → 从开始节点运行字段移除,不向后端提交空字段
+export function applyHomeFormValues(nodes: any[], formValues: Record, formFileNames?: Record): void {
if (!Array.isArray(nodes)) return;
- for (const node of nodes) {
- const code = String(node?.nodeCode || '').toLowerCase();
- for (const f of collectHomeFormFields(node)) {
- const key = `${node.id || node.nodeCode}|${f.path}`;
- const val = formValues[key];
- // 仅跳过未初始化的 key;null(如数字清空)也要写回
- if (val === undefined) continue;
- // 上传类型字段未选文件 → 不提交,从 DSL 移除该字段
- if (isUploadType(f) && isEmptyUploadValue(val)) {
- removeUploadField(node, code, f);
- continue;
- }
- if (code === 'model') {
- const target = resolvePath(node?.modelConfig?.modelRequestParams, f.path);
- if (target && typeof target === 'object' && !Array.isArray(target)) target.value = val;
- } else if (code === 'form') {
- const item = (node?.outputConfig || []).find((o: any) => o && o.field === f.path);
- if (item) item.value = val;
- } else if (f.__isHttpBodyChild && f.bodyKey && Array.isArray(node?.formConfig)) {
- const bodyField = node.formConfig.find((x: any) => x && x.field === 'body');
- if (bodyField?.value && typeof bodyField.value === 'object' && !Array.isArray(bodyField.value) && bodyField.value[f.bodyKey]) {
- bodyField.value[f.bodyKey].value = val;
- }
- }
+ const startNode = nodes.find((n: any) => String(n?.nodeCode || '').toLowerCase() === '__start__');
+ if (!startNode || !Array.isArray(startNode.outputConfig)) return;
+ for (let i = startNode.outputConfig.length - 1; i >= 0; i--) {
+ const f = startNode.outputConfig[i];
+ if (!f || typeof f !== 'object') continue;
+ if (f.valueSource && typeof f.valueSource === 'object') {
+ // 引用其他节点输出的字段:值由上游节点运行时提供,清空保存时的快照,避免后端误用过期值
+ if ('value' in f) delete f.value;
+ continue;
+ }
+ const field = f.field !== undefined ? f.field : f.path;
+ if (field === undefined || field === '') continue;
+ const key = `${startNode.id || startNode.nodeCode}|${field}`;
+ const val = formValues[key];
+ // 仅跳过未初始化的 key;null(如数字清空)也要写回
+ if (val === undefined) continue;
+ // 上传类型字段未选文件 → 不提交,从开始节点运行字段移除
+ const ft = String(f.fieldType || f.type || '');
+ const isUpload = ft === 'upload' || ft === 'uploadMultiple' || ft === 'fileUpload' || ft === 'file';
+ if (isUpload && isEmptyUploadValue(val)) {
+ startNode.outputConfig.splice(i, 1);
+ continue;
+ }
+ f.value = val;
+ // 上传字段附带文件名:与 value 类型对齐(单文件字符串/多文件数组),随开始节点一并传给后端
+ if (formFileNames && formFileNames[key] !== undefined) {
+ f.fileName = formFileNames[key];
}
}
}
diff --git a/src/views/home/utils/wsExecute.ts b/src/views/home/utils/wsExecute.ts
index 2548894..4f7bf90 100644
--- a/src/views/home/utils/wsExecute.ts
+++ b/src/views/home/utils/wsExecute.ts
@@ -91,17 +91,15 @@ export function sendAgentStart(ws: WebSocket, p: { modelId?: string | number; qu
);
}
-/** 工作流执行:发送 flowContent(保持原无 type 结构,向后兼容);flowId 从握手 query 移到帧内 */
-export function sendWorkflowStart(
- ws: WebSocket,
- p: { flowId?: string | number; flowContent: any; systemPrompt?: string; question?: string }
-): void {
+/** 工作流执行:发送 { type, payload:{ flowId, flowContent } },对齐后端 socket 传参结构 */
+export function sendWorkflowStart(ws: WebSocket, p: { flowId?: string | number; flowContent: any }): void {
ws.send(
JSON.stringify({
- flowId: p.flowId != null ? String(p.flowId) : '',
- question: p.question || '',
- flowContent: p.flowContent,
- systemPrompt: p.systemPrompt || '',
+ type: 'workflow',
+ payload: {
+ flowId: p.flowId != null ? String(p.flowId) : '',
+ flowContent: p.flowContent,
+ },
})
);
}
diff --git a/src/views/settings/workflow/component/ModelField.vue b/src/views/settings/workflow/component/ModelField.vue
index b7058d8..a910877 100644
--- a/src/views/settings/workflow/component/ModelField.vue
+++ b/src/views/settings/workflow/component/ModelField.vue
@@ -32,39 +32,23 @@
-
-
-
-
+
+
+
+
{{ variantLabel(idx) }}
-
-
-
- setArrayPrimitive(idx, v)" size="small" />
-
-
-
-
-
-
- #{{ idx + 1 }}
-
-
- setArrayPrimitive(idx, v)" size="small" />
+ #{{ idx + 1 }}
+
+ setTemplatePrimitive(item, v)" size="small" />
@@ -177,7 +161,6 @@
diff --git a/src/views/settings/workflow/component/NodeConfigPanel.vue b/src/views/settings/workflow/component/NodeConfigPanel.vue
index 91eb104..c37e0f1 100644
--- a/src/views/settings/workflow/component/NodeConfigPanel.vue
+++ b/src/views/settings/workflow/component/NodeConfigPanel.vue
@@ -202,6 +202,27 @@
+
+
+
+ 子流程配置
+
+ 选择工作流
+
+
+ {{ subFlowConfig.workflowName || subFlowConfig.workflowId }}
+
+
+
+
+ 引入参数
+
+
+
自定义字段
@@ -255,6 +276,8 @@ import KeyValueEditor from './KeyValueEditor.vue';
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
import ModelParamsForm from './ModelParamsForm.vue';
import PromptEditor from './PromptEditor.vue';
+import SubFlowParams from './SubFlowParams.vue';
+import type { SubFlowField } from './subFlowTypes';
import { JsonEditor } from '/@/components/json-schema-editor';
interface NodeData {
@@ -269,6 +292,8 @@ interface NodeData {
patchLayout?: boolean;
isSaveFile?: boolean;
runFormFields?: any[];
+ // 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
+ subFlowConfig?: any;
}
interface NodeConfig {
@@ -306,6 +331,11 @@ const isStartNode = computed(() => props.selectedNode?.data?.nodeCode === '__sta
// 开始节点运行表单字段摘要(数据来自 index.vue 聚合写回)
const runFormFields = computed(() => props.selectedNode?.data?.runFormFields || []);
+// 子流程节点(sub_flow):是否当前选中节点为子流程
+const isSubFlowNode = computed(() => props.selectedNode?.data?.nodeCode === 'sub_flow');
+// 子流程引入配置(编辑器态 subFlowConfig;未引入则为 null)
+const subFlowConfig = computed(() => props.selectedNode?.data?.subFlowConfig || null);
+
// fieldType 可读标签
const fieldTypeLabel = (ft: string) => {
const map: Record = {
@@ -330,6 +360,8 @@ const emit = defineEmits<{
(e: 'openSkillSelector'): void;
(e: 'removeSkill'): void;
(e: 'update:patchLayout', value: boolean): void;
+ (e: 'openWorkflowSelector'): void;
+ (e: 'removeWorkflow'): void;
}>();
// Schema 编辑器弹窗
@@ -433,6 +465,23 @@ const updateModelRequestParams = (params: Record | null) => {
emit('update:selectedNode', updatedNode);
};
+// 子流程引入参数就地变更(ModelField 已就地写共享引用):
+// 仅刷新节点引用同步 VueFlow / 父组件状态;fields 为同一数组引用,避免与 SubFlowParams 深 watch 形成循环
+const updateSubFlowFields = (fields: SubFlowField[]) => {
+ if (!props.selectedNode?.data || !props.selectedNode.data.subFlowConfig) return;
+ const updatedNode = {
+ ...props.selectedNode,
+ data: {
+ ...props.selectedNode.data,
+ subFlowConfig: {
+ ...props.selectedNode.data.subFlowConfig,
+ fields,
+ },
+ },
+ };
+ 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);
diff --git a/src/views/settings/workflow/component/SubFlowParams.vue b/src/views/settings/workflow/component/SubFlowParams.vue
new file mode 100644
index 0000000..1350bcd
--- /dev/null
+++ b/src/views/settings/workflow/component/SubFlowParams.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/settings/workflow/component/WorkflowSelector.vue b/src/views/settings/workflow/component/WorkflowSelector.vue
new file mode 100644
index 0000000..9db3877
--- /dev/null
+++ b/src/views/settings/workflow/component/WorkflowSelector.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+ 搜索
+
+
+
+
+
+
+
+
{{ workflow.flowName || workflow.name || '未命名工作流' }}
+
{{ workflow.description || '暂无描述' }}
+
+
+
+
+
+
+ 取消
+ 确定
+
+
+
+
+
+
+
diff --git a/src/views/settings/workflow/component/modelParamUtils.ts b/src/views/settings/workflow/component/modelParamUtils.ts
index e6562d3..ea27e37 100644
--- a/src/views/settings/workflow/component/modelParamUtils.ts
+++ b/src/views/settings/workflow/component/modelParamUtils.ts
@@ -128,9 +128,47 @@ export function stripReadonlyFields(params: any): any {
return params;
}
+// 剔除 array 类型字段的实例值(value):后端模型定义中 array 仅保存结构/模板
+// (enumValues / attrs),前端渲染时从模板复制并填充的 value 实例是冗余数据,
+// 保存工作流时不提交。递归处理嵌套结构(array 字段可嵌套于 enumValues 模板中,
+// 如 messages.enumValues[0].attrs.content 仍是 array,同样需删除 value)。
+export function removeArrayValueInstances(params: any): any {
+ if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
+ // 单个字段定义(带 type):array 删实例值并递归模板;object 递归 attrs
+ if (typeof params.type === 'string') {
+ if (params.type === 'array') {
+ delete params.value;
+ if (params.attrs && typeof params.attrs === 'object') {
+ if (Array.isArray(params.attrs)) params.attrs.forEach((it: any) => removeArrayValueInstances(it));
+ else removeArrayValueInstances(params.attrs);
+ }
+ if (Array.isArray(params.enumValues)) params.enumValues.forEach((tpl: any) => removeArrayValueInstances(tpl));
+ } else if (params.type === 'object' && params.attrs && typeof params.attrs === 'object' && !Array.isArray(params.attrs)) {
+ removeArrayValueInstances(params.attrs);
+ }
+ return params;
+ }
+ // 字段定义容器 { key: fieldDef }:逐个字段递归
+ for (const key of Object.keys(params)) {
+ removeArrayValueInstances(params[key]);
+ }
+ return params;
+}
+
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
-// 单个暴露叶子字段(路径带实例索引,如 "messages.value[0].attrs.content")
+// array 的模板元素列表(与 ModelField.arrayTemplates 优先级一致):
+// enumValues 数组 → attrs 数组 → 单个 attrs 对象。勾选/值都跟随模板,故 path 用
+// enumValues[i] 定位模板,collect 与 resolve 共用本函数保持文法一致。
+function arrayTemplatesOf(def: any): any[] {
+ if (!def || typeof def !== 'object') return [];
+ if (Array.isArray(def.enumValues) && def.enumValues.length) return def.enumValues;
+ if (Array.isArray(def.attrs) && def.attrs.length) return def.attrs;
+ if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) return [{ type: 'object', attrs: def.attrs }];
+ return [];
+}
+
+// 单个暴露叶子字段(路径带模板索引,如 "messages.enumValues[0].attrs.content")
export interface ExposedField {
path: string;
label: string;
@@ -139,13 +177,17 @@ export interface ExposedField {
required: boolean;
options?: any[];
value?: any;
+ defaultValue?: any; // 默认值:首页初始化/回显用
+ fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
+ valueSource?: any; // 引用上游节点输出({ nodeId, field });有则首页只读展示
+ multiple?: boolean; // 多文件上传标记
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
}
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
// 路径文法(与 restoreRuntimeShow 共用):
// object 子字段 → 父路径 + ".attrs." + 子key
-// array 实例元素 → 父路径 + ".value[i]"
+// array 模板元素 → 父路径 + ".enumValues[i]"
// 原始值数组元素(非 object 包装)不可勾选,跳过
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
if (!params || typeof params !== 'object') return [];
@@ -161,14 +203,14 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
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`));
- }
- });
- }
+ // 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
+ // 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
+ const templates = arrayTemplatesOf(def);
+ templates.forEach((tpl: any, i: number) => {
+ if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
+ result.push(...collectExposedFields(tpl.attrs, `${path}.enumValues[${i}].attrs`));
+ }
+ });
} else if (def.runtimeShow === true) {
// 叶子且已勾选
result.push({
@@ -179,6 +221,10 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
required: !!def.required,
options: Array.isArray(def.options) ? def.options : undefined,
value: def.value,
+ defaultValue: def.defaultValue,
+ fieldConstraint: def.fieldConstraint && typeof def.fieldConstraint === 'object' ? def.fieldConstraint : undefined,
+ valueSource: def.valueSource && typeof def.valueSource === 'object' ? def.valueSource : undefined,
+ multiple: def.multiple || def.fieldType === 'uploadMultiple' || undefined,
});
}
}
@@ -208,14 +254,22 @@ function resolvePath(params: any, path: string): any {
cur = cur.attrs;
if (!cur || typeof cur !== 'object') return undefined;
} else {
- const m = seg.match(/^value\[(\d+)\]$/);
- if (m) {
- const idx = Number(m[1]);
+ const mv = seg.match(/^value\[(\d+)\]$/);
+ if (mv) {
+ const idx = Number(mv[1]);
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
cur = cur.value[idx];
- } else {
- cur = cur[seg];
+ continue;
}
+ const me = seg.match(/^enumValues\[(\d+)\]$/);
+ if (me) {
+ const idx = Number(me[1]);
+ const templates = arrayTemplatesOf(cur);
+ if (idx >= templates.length) return undefined;
+ cur = templates[idx];
+ continue;
+ }
+ cur = cur[seg];
}
}
return cur;
diff --git a/src/views/settings/workflow/component/subFlowTypes.ts b/src/views/settings/workflow/component/subFlowTypes.ts
new file mode 100644
index 0000000..2a528c7
--- /dev/null
+++ b/src/views/settings/workflow/component/subFlowTypes.ts
@@ -0,0 +1,58 @@
+// 子流程节点(sub_flow)引入工作流的类型定义(workflow 独立实现,不依赖内容创作)
+
+// ===== 编辑器态(node.data.subFlowConfig)=====
+export interface SubFlowField {
+ // = 目标工作流开始节点 runFormFields 的 field(后端匹配键,不可改)
+ field: string;
+ label: string;
+ // 控件类型:input/number/textarea/switch/select/upload/uploadMultiple
+ type: string;
+ fieldType: string;
+ required: boolean;
+ // 未引用且未勾选表单展示时的静态值(编辑器可填)
+ value?: any;
+ defaultValue?: any;
+ // upload {fileTypes,maxFileSize,maxFileCount} / number {minValue,maxValue}
+ fieldConstraint?: any;
+ // select 选项
+ options?: any[];
+ // 多文件上传标记
+ multiple?: boolean;
+ // 引用上级节点输出(主工作流上游);有值则编辑器内只读引用展示
+ valueSource?: { nodeId: string; field: string } | null;
+ // 编辑器内「表单展示」勾选(ModelField 用 runtimeShow)
+ runtimeShow?: boolean;
+}
+
+export interface SubFlowConfig {
+ // 目标工作流 id(后端执行 sub_flow 时引入)
+ workflowId: string;
+ workflowName: string;
+ // 生成次数(sub_flow 执行并发数;未填为 0)
+ maxConcurrency?: number;
+ fields: SubFlowField[];
+}
+
+// ===== DSL 保存态(node.subConfig,runtimeShow → isFormField)=====
+export interface SubFlowDslField {
+ field: string;
+ label: string;
+ type: string;
+ fieldType: string;
+ required: boolean;
+ value?: any;
+ defaultValue?: any;
+ fieldConstraint?: any;
+ options?: any[] | null;
+ multiple?: boolean;
+ valueSource?: { nodeId: string; field: string } | null;
+ // 勾选表单展示 → 聚合进主工作流开始节点(首页表单可填)
+ isFormField?: boolean;
+}
+
+export interface SubFlowConfigDsl {
+ workflowId: string;
+ workflowName: string;
+ maxConcurrency?: number;
+ fields: SubFlowDslField[];
+}
diff --git a/src/views/settings/workflow/index.vue b/src/views/settings/workflow/index.vue
index 98e35bf..de3abb1 100644
--- a/src/views/settings/workflow/index.vue
+++ b/src/views/settings/workflow/index.vue
@@ -26,6 +26,8 @@
@open-skill-selector="showSkillSelector = true"
@remove-skill="handleRemoveSkill"
@update:patch-layout="handleTogglePatchLayout"
+ @open-workflow-selector="showWorkflowSelector = true"
+ @remove-workflow="handleRemoveWorkflow"
/>
@@ -84,6 +86,13 @@
+
+
+
@@ -103,13 +112,17 @@ import {
type WorkflowItem,
} from '/@/api/settings/creation';
import { checkIsSuperAdmin } from '/@/api/system/user';
-import { getNodeLibraryList, type NodeLibraryGroup } from '/@/api/settings/workflow';
+import { getNodeLibraryList, getSubFlowWorkflowDetail, type NodeLibraryGroup } from '/@/api/settings/workflow';
import NodeConfigPanel from './component/NodeConfigPanel.vue';
+import WorkflowSelector from './component/WorkflowSelector.vue';
+import type { SubFlowConfig, SubFlowField } from './component/subFlowTypes';
import {
stripReadonlyFields,
collectExposedFields,
restoreRuntimeShow,
isEqual,
+ deepClone,
+ removeArrayValueInstances,
type ExposedField,
} from './component/modelParamUtils';
import NodeLibraryPanel from './component/NodeLibraryPanel.vue';
@@ -119,8 +132,11 @@ import ModelSelector from './component/ModelSelector.vue';
import SkillSelector from './component/SkillSelector.vue';
import FlowNode from './component/FlowNode.vue';
-// 开始节点「运行表单字段」:在暴露清单基础上标注来源节点
-interface RunFormField extends ExposedField {
+// 开始节点「运行表单字段」:在暴露清单基础上标注来源节点;field 为统一标识
+// (model 字段 = path;form 节点自定义字段 = 字段名),供首页按 field 渲染/写回
+interface RunFormField extends Omit {
+ field: string;
+ path?: string;
nodeId: string;
nodeLabel: string;
}
@@ -157,6 +173,8 @@ interface NodeData {
isSaveFile?: boolean;
preTool?: string | null;
runFormFields?: RunFormField[]; // 仅开始节点使用
+ // 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
+ subFlowConfig?: SubFlowConfig | null;
}
const { addNodes, addEdges, findNode, removeNodes, getNodes, updateNode } = useVueFlow();
@@ -239,6 +257,10 @@ const isSuperAdmin = ref(false);
const showSkillSelector = ref(false);
const selectedSkillData = ref(null);
+// 子流程工作流选择器相关状态
+const showWorkflowSelector = ref(false);
+const selectedWorkflowData = ref(null);
+
const deleteSelectedNode = async () => {
if (!selectedNode.value?.data) return;
@@ -301,6 +323,9 @@ const onNodeClick = (event: { node: Node }) => {
selectedModelData.value = mc?.modelId ? { id: mc.modelId, modelName: mc.modelName || '', modelType: mc.modelType } : null;
const sk = event.node.data?.skillName;
selectedSkillData.value = sk ? { name: sk } : null;
+ // 子流程:回填预选工作流(仅部分信息,WorkflowSelector 按名称匹配当前页高亮)
+ const sc = event.node.data?.subFlowConfig;
+ selectedWorkflowData.value = sc?.workflowId ? { id: sc.workflowId, name: sc.workflowName || '' } : null;
};
const updateSelectedNode = (updatedNode: Node) => {
@@ -423,6 +448,112 @@ const handleRemoveSkill = () => {
ElMessage.success('技能已移除');
};
+// 子流程:选择已有工作流引入其开始参数
+const handleWorkflowConfirm = async (workflow: any) => {
+ // 捕获当前选中节点:await 拉详情期间用户可能切换节点,防止写入错误节点
+ const targetNode = selectedNode.value;
+ if (!targetNode?.data) return;
+
+ // 防自引用:不能把当前正在编辑的工作流引入自身(更深层自引用环由后端执行期兜底)
+ if (workflow.id && currentEditingWorkflowId.value && String(workflow.id) === String(currentEditingWorkflowId.value)) {
+ ElMessage.warning('不能将当前工作流引入自身');
+ return;
+ }
+
+ let fields: SubFlowField[] = [];
+ try {
+ const res = await getSubFlowWorkflowDetail(workflow.id);
+ // 响应结构容错:详情可能直接返回 nodes/edges,也可能包在 flowContent 中
+ const detail = res.data?.flowContent || res.data || {};
+ const startNode = (detail.nodes || []).find(
+ (nd: any) => nd && String(nd.nodeCode || '').toLowerCase() === '__start__'
+ );
+ const outputs = Array.isArray(startNode?.outputConfig) ? startNode.outputConfig : [];
+ fields = outputs
+ .filter((o: any) => o && typeof o === 'object' && (o.field !== undefined || o.path !== undefined))
+ .map((o: any) => {
+ const field = o.field !== undefined ? o.field : o.path;
+ const ft = String(o.fieldType || o.type || 'input');
+ const isUploadMultiple = ft === 'uploadMultiple';
+ return {
+ field,
+ label: o.label || String(field),
+ // 编辑器渲染归一到 upload(ModelField 控件识别),保留 multiple 供首页识别多文件上传
+ type: isUploadMultiple ? 'upload' : ft,
+ fieldType: isUploadMultiple ? 'upload' : ft,
+ required: Boolean(o.required),
+ value: o.value !== undefined ? o.value : o.defaultValue ?? '',
+ defaultValue: o.defaultValue,
+ fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
+ options: Array.isArray(o.options) ? o.options : undefined,
+ multiple: isUploadMultiple || o.multiple || undefined,
+ // 导入时清空目标工作流自带的引用(其 nodeId 指向目标内部节点,在主工作流无意义)
+ valueSource: null,
+ runtimeShow: false,
+ };
+ });
+ } catch {
+ // 详情拉取失败:仅记空字段(错误已由全局拦截器提示)
+ }
+
+ // 拉详情期间若已切换选中节点,则中止写入,避免配置落到错误节点上
+ if (selectedNode.value !== targetNode) {
+ ElMessage.warning('已切换节点,请重新选择子流程节点后操作');
+ return;
+ }
+
+ const subFlowConfig: SubFlowConfig = {
+ workflowId: workflow.id || '',
+ workflowName: workflow.flowName || workflow.name || '',
+ fields,
+ };
+
+ const updatedNode: Node = {
+ ...targetNode,
+ data: {
+ ...targetNode.data,
+ subFlowConfig,
+ },
+ };
+
+ selectedNode.value = updatedNode;
+ selectedWorkflowData.value = workflow;
+ // 同步更新到 VueFlow 内部状态
+ updateNode(updatedNode.id, updatedNode);
+
+ const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
+ if (index >= 0) {
+ nodes.value[index] = updatedNode;
+ }
+
+ ElMessage.success(`已引入工作流:${workflow.flowName || workflow.name || workflow.id}`);
+};
+
+// 移除子流程引入的工作流
+const handleRemoveWorkflow = () => {
+ if (!selectedNode.value?.data) return;
+
+ const updatedNode: Node = {
+ ...selectedNode.value,
+ data: {
+ ...selectedNode.value.data,
+ subFlowConfig: undefined,
+ },
+ };
+
+ selectedNode.value = updatedNode;
+ selectedWorkflowData.value = null;
+ // 同步更新到 VueFlow 内部状态
+ updateNode(updatedNode.id, updatedNode);
+
+ const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
+ if (index >= 0) {
+ nodes.value[index] = updatedNode;
+ }
+
+ ElMessage.success('已移除子流程工作流');
+};
+
// 切换贴片布局
const handleTogglePatchLayout = (value: boolean) => {
if (!selectedNode.value?.data) return;
@@ -457,14 +588,103 @@ const syncRunFormFields = () => {
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,
- });
+ const nodeCode = n.data?.nodeCode || '';
+ if (nodeCode === 'model') {
+ const params = n.data?.modelConfig?.modelRequestParams;
+ if (!params || typeof params !== 'object') continue;
+ for (const f of collectExposedFields(params)) {
+ collected.push({
+ ...f,
+ field: f.path,
+ nodeId: n.id,
+ nodeLabel: n.data?.label || n.id,
+ });
+ }
+ } else if (nodeCode === 'form') {
+ // form 节点:自定义表单字段即运行表单字段(无 path,field 为字段名)
+ const formFields = Array.isArray(n.data?.formConfig) ? n.data.formConfig : [];
+ for (const ff of formFields) {
+ if (!ff || typeof ff !== 'object') continue;
+ const fieldName = ff.field || ff.label || '';
+ if (!fieldName) continue;
+ const isUploadMultiple = ff.type === 'uploadMultiple';
+ collected.push({
+ field: fieldName,
+ label: ff.label || fieldName,
+ fieldType: ff.type || 'input',
+ type: ff.type || 'input',
+ required: Boolean(ff.required),
+ value: ff.value ?? '',
+ defaultValue: ff.defaultValue,
+ fieldConstraint: isUploadMultiple
+ ? {
+ ...(ff.fileTypes ? { fileTypes: ff.fileTypes } : {}),
+ ...(ff.maxFileSize !== undefined && ff.maxFileSize !== null ? { maxFileSize: ff.maxFileSize } : {}),
+ ...(ff.maxFileCount !== undefined && ff.maxFileCount !== null ? { maxFileCount: ff.maxFileCount } : {}),
+ }
+ : ff.fieldConstraint && typeof ff.fieldConstraint === 'object'
+ ? ff.fieldConstraint
+ : undefined,
+ multiple: isUploadMultiple || undefined,
+ nodeId: n.id,
+ nodeLabel: n.data?.label || n.id,
+ });
+ }
+ } else {
+ // 子流程节点:引入工作流的开始参数中勾选「表单展示」的字段聚合进运行表单
+ if (nodeCode === 'sub_flow') {
+ const subFields = n.data?.subFlowConfig?.fields;
+ if (Array.isArray(subFields)) {
+ for (const f of subFields) {
+ if (!f || typeof f !== 'object' || f.runtimeShow !== true) continue;
+ const isNumeric = f.fieldType === 'number' || f.type === 'number' || f.type === 'inputNumber';
+ const isUploadMultiple = f.fieldType === 'uploadMultiple';
+ collected.push({
+ field: f.field,
+ label: f.label || f.field,
+ fieldType: isNumeric ? 'number' : f.type || 'input',
+ type: isNumeric ? 'number' : f.type || 'input',
+ required: Boolean(f.required),
+ value: f.value ?? f.defaultValue ?? '',
+ defaultValue: f.defaultValue,
+ fieldConstraint: f.fieldConstraint && typeof f.fieldConstraint === 'object' ? f.fieldConstraint : undefined,
+ valueSource: f.valueSource && typeof f.valueSource === 'object' ? f.valueSource : undefined,
+ options: Array.isArray(f.options) ? f.options : undefined,
+ multiple: isUploadMultiple || f.multiple || undefined,
+ nodeId: n.id,
+ nodeLabel: n.data?.label || n.id,
+ });
+ }
+ }
+ }
+
+ // 其它节点(如 sub_flow):presetOption 中标记 isFormField 的字段作为运行表单字段
+ const defs = nodeConfigMap.value.get(nodeCode)?.formConfig || [];
+ if (defs.length === 0) continue;
+ const formConfig = Array.isArray(n.data?.formConfig) ? n.data.formConfig : [];
+ for (const def of defs) {
+ if (!def || typeof def !== 'object' || def.isFormField !== true) continue;
+ const entry = formConfig.find((f: any) => f && f.field === def.field);
+ const isNumeric = def.constraint?.type === 'int' || def.constraint?.type === 'number' || def.type === 'number' || def.type === 'inputNumber';
+ collected.push({
+ field: def.field,
+ label: def.label || def.field,
+ fieldType: isNumeric ? 'number' : def.type || 'input',
+ type: isNumeric ? 'number' : def.type || 'input',
+ required: Boolean(def.required),
+ value: entry?.value ?? def.value ?? '',
+ defaultValue: def.value,
+ fieldConstraint:
+ def.constraint && typeof def.constraint === 'object'
+ ? {
+ ...(def.constraint.min !== undefined && def.constraint.min !== null ? { minValue: def.constraint.min } : {}),
+ ...(def.constraint.max !== undefined && def.constraint.max !== null ? { maxValue: def.constraint.max } : {}),
+ }
+ : undefined,
+ nodeId: n.id,
+ nodeLabel: n.data?.label || n.id,
+ });
+ }
}
}
@@ -558,8 +778,8 @@ const getNodeOutputFields = (node: Node): UpstreamField[]
.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 || '' }));
+ // 开始节点:运行表单字段(被勾选的模型参数 + form 自定义字段)
+ return (node.data?.runFormFields || []).map((f: any) => ({ field: f.field || f.path || '', label: f.label || f.field || f.path || '' }));
}
return [];
};
@@ -753,7 +973,7 @@ const editWorkflow = async (workflow: WorkflowItem) => {
};
// 从 DSL 节点重建 formConfig(新格式:outputConfig;旧格式:formConfig 兜底)
-const buildNodeFormConfigFromDsl = (n: any) => {
+const buildNodeFormConfigFromDsl = (n: any, startRunFields: any[] = []) => {
// form 节点:自定义字段(完整结构 {type,field,label,value,required},兼容命名对象 [{key:value}])
if (n.nodeCode === 'form' && Array.isArray(n.outputConfig)) {
return n.outputConfig.map((o: any) => {
@@ -805,7 +1025,10 @@ const buildNodeFormConfigFromDsl = (n: any) => {
});
return { ...def, value: out?.value ?? '', expand };
}
- return { ...def, value: out?.value ?? '' };
+ // isFormField:true 字段不随节点 outputConfig 保存(值在开始节点运行表单),
+ // 从开始节点反查恢复,保证编辑器参数面板回显与运行表单一致
+ const startValue = startRunFields.find((r: any) => r && r.field === def.field)?.value;
+ return { ...def, value: out?.value ?? startValue ?? '' };
});
}
// 旧格式兜底
@@ -857,7 +1080,86 @@ const buildOutputConfig = (node: Node) => {
: {}),
}));
}
- return null;
+ // 其它带 presetOption 的节点(如 sub_flow):isFormField:true 的字段已聚合进开始节点运行表单,
+ // 此处仅序列化 isFormField !== true 的节点配置参数,保证重开后编辑器参数面板回显
+ const defs = nodeConfigMap.value.get(nodeCode)?.formConfig || [];
+ if (defs.length === 0) return null;
+ const formConfig = Array.isArray(node.data?.formConfig) ? node.data.formConfig : [];
+ return defs
+ .filter((def: any) => def && typeof def === 'object' && def.isFormField !== true)
+ .map((def: any) => {
+ const entry = formConfig.find((f: any) => f && f.field === def.field);
+ return { type: def.type || 'input', field: def.field, label: def.label || def.field, value: entry?.value ?? '' };
+ });
+};
+
+// 子流程节点:编辑器态 subFlowConfig → DSL 保存态 subConfig(runtimeShow → isFormField)
+const serializeSubFlowConfig = (config: SubFlowConfig | null | undefined, node?: Node, startNodeId = '') => {
+ if (!config || !config.workflowId) return null;
+ // 生成次数(节点库 presetOption 的 maxConcurrency):勾选表单展示时聚合进开始节点表单,
+ // 此处同步保存到 subConfig,保证 sub_flow 节点自身也带该配置(后端执行时可读)
+ const formConfig = Array.isArray(node?.data?.formConfig) ? node.data.formConfig : [];
+ const mcEntry = formConfig.find((f: any) => f && f.field === 'maxConcurrency');
+ const mcValue = mcEntry?.value;
+ const maxConcurrency = mcValue !== undefined && mcValue !== null && mcValue !== '' ? Number(mcValue) : 0;
+ return {
+ workflowId: config.workflowId,
+ workflowName: config.workflowName || '',
+ maxConcurrency,
+ fields: (config.fields || []).map((f) => ({
+ field: f.field,
+ label: f.label || f.field,
+ type: f.type || 'input',
+ fieldType: f.fieldType || f.type || 'input',
+ required: Boolean(f.required),
+ value: f.value,
+ defaultValue: f.defaultValue,
+ fieldConstraint: f.fieldConstraint ?? null,
+ options: Array.isArray(f.options) ? f.options : null,
+ multiple: Boolean(f.multiple),
+ // 值来源契约(后端统一 { nodeId, fieldName }):
+ // - 勾选表单展示:值来自主工作流开始节点(首页表单),指向开始节点
+ // - 引用上级输出:值来自主工作流上游节点,统一转 fieldName
+ // - 其余:无引用(静态默认值)
+ valueSource: f.runtimeShow
+ ? { nodeId: startNodeId, fieldName: f.field }
+ : f.valueSource && typeof f.valueSource === 'object'
+ ? { nodeId: f.valueSource.nodeId, fieldName: f.valueSource.field }
+ : null,
+ isFormField: Boolean(f.runtimeShow),
+ })),
+ };
+};
+
+// 子流程节点:DSL 保存态 subConfig → 编辑器态 subFlowConfig(isFormField → runtimeShow)
+const buildSubFlowConfigFromDsl = (subConfig: any, startNodeId = '') => {
+ if (!subConfig || !subConfig.workflowId) return null;
+ return {
+ workflowId: subConfig.workflowId,
+ workflowName: subConfig.workflowName || '',
+ maxConcurrency: subConfig.maxConcurrency ?? 0,
+ fields: (Array.isArray(subConfig.fields) ? subConfig.fields : []).map((f: any) => ({
+ field: f.field,
+ label: f.label || f.field,
+ type: f.type || 'input',
+ fieldType: f.fieldType || f.type || 'input',
+ required: Boolean(f.required),
+ value: f.value !== undefined ? f.value : f.defaultValue ?? '',
+ defaultValue: f.defaultValue,
+ fieldConstraint: f.fieldConstraint && typeof f.fieldConstraint === 'object' ? f.fieldConstraint : undefined,
+ options: Array.isArray(f.options) ? f.options : undefined,
+ multiple: Boolean(f.multiple) || f.fieldType === 'uploadMultiple',
+ valueSource: (() => {
+ const vs = f.valueSource;
+ // 指向开始节点:值为表单展示(首页可填),编辑器不显示为引用(勾选由 isFormField 恢复)
+ if (vs && typeof vs === 'object' && vs.nodeId && vs.nodeId === startNodeId) return null;
+ // 引用上级:后端 fieldName 转回前端 field 结构
+ if (vs && typeof vs === 'object') return { nodeId: vs.nodeId, field: vs.fieldName ?? vs.field };
+ return null;
+ })(),
+ runtimeShow: Boolean(f.isFormField),
+ })),
+ };
};
// 从 DSL 构建模型配置:剔除只读字段 + 用 modelFormFields 还原勾选(幂等,兼容旧 DSL)
@@ -894,8 +1196,28 @@ const loadWorkflowFromDsl = (dsl: any) => {
if (!dsl) return;
try {
+ // 开始节点 id 与其运行表单字段(outputConfig):
+ // - 运行表单供其它节点 isFormField:true 字段回显反查
+ // - 开始节点 id 用于识别 sub_flow 引入参数中"指向开始节点"的 valueSource(表单展示)
+ const startDslNode = (dsl.nodes || []).find((n: any) => n.nodeCode === '__start__');
+ const startNodeId = startDslNode?.id || '';
+ const startRunFields = startDslNode?.outputConfig || [];
const loadedNodes = (dsl.nodes || []).map((n: any) => {
const isStart = n.nodeCode === '__start__';
+ // 子流程节点:参数面板的生成次数为空时,用 subConfig.maxConcurrency 兜底恢复(兼容边界数据)
+ let formConfig = buildNodeFormConfigFromDsl(n, startRunFields);
+ if (n.nodeCode === 'sub_flow' && Array.isArray(formConfig) && formConfig.length > 0) {
+ const mcVal = n.subConfig?.maxConcurrency;
+ if (mcVal !== undefined && mcVal !== null && mcVal !== 0) {
+ const mcIdx = formConfig.findIndex((f: any) => f && f.field === 'maxConcurrency');
+ if (mcIdx >= 0) {
+ const cur = formConfig[mcIdx]?.value;
+ if (cur === '' || cur === undefined || cur === null) {
+ formConfig = formConfig.map((f: any, i: number) => (i === mcIdx ? { ...f, value: mcVal } : f));
+ }
+ }
+ }
+ }
return {
id: n.id,
type: isStart ? 'input' : 'default',
@@ -904,7 +1226,7 @@ const loadWorkflowFromDsl = (dsl: any) => {
label: n.name || '',
nodeCode: n.nodeCode,
desc: n.desc || '',
- formConfig: buildNodeFormConfigFromDsl(n),
+ formConfig,
modelConfig: buildModelConfigFromDsl(n),
skillName: n.skillName || null,
prompt: n.prompt || '',
@@ -912,6 +1234,8 @@ const loadWorkflowFromDsl = (dsl: any) => {
patchLayout: n.patchLayout || false,
isSaveFile: Boolean(n.isSaveFile),
preTool: n.preTool ?? null,
+ // 子流程节点:恢复引入的工作流配置(无 subConfig 时返回 null,兼容旧 DSL)
+ ...(n.nodeCode === 'sub_flow' ? { subFlowConfig: buildSubFlowConfigFromDsl(n.subConfig, startNodeId) } : {}),
// 开始节点运行表单字段:新格式存 outputConfig;旧 DSL 顶层 runFormFields 兜底兼容
...(isStart
? {
@@ -981,12 +1305,33 @@ const confirmSaveWorkflow = async () => {
}
const startNode = nodes.value.find((n) => isStartNode(n));
+
+ // 保存前检测聚合运行表单字段重名(不同节点同名 field 在首页表单会冲突;仅告警不阻断)
+ const runFields = Array.isArray(startNode?.data?.runFormFields) ? startNode.data.runFormFields : [];
+ const seenFields = new Set();
+ const dupFields = runFields.filter((f: any) => {
+ if (!f || typeof f.field !== 'string') return false;
+ if (seenFields.has(f.field)) return true;
+ seenFields.add(f.field);
+ return false;
+ });
+ if (dupFields.length > 0) {
+ const dupNames = [...new Set(dupFields.map((f: any) => f.field))];
+ ElMessage.warning(`存在重名运行字段:${dupNames.join('、')},首页表单可能出现冲突`);
+ }
+
const workflowDsl = {
version: '1.0.0',
startNodeId: startNode?.id || '',
nodes: nodes.value.map((n) => {
const gNode = n as any; // VueFlow 运行时会注入 dimensions(渲染尺寸)
const nodeCode = n.data?.nodeCode || 'unknown';
+ const rawModelParams = n.data?.modelConfig?.modelRequestParams ?? null;
+ // 先基于含实例值的完整结构收集勾选的「表单展示」字段
+ const savedModelFormFields = rawModelParams ? collectExposedFields(rawModelParams) : undefined;
+ // 数组字段的实例值(value)由前端从模板复制而来,与 enumValues 模板重复,
+ // 保存时不提交;deepClone 避免污染节点运行时状态
+ const savedModelRequestParams = rawModelParams ? removeArrayValueInstances(deepClone(rawModelParams)) : null;
return {
id: n.id,
nodeCode,
@@ -1000,7 +1345,8 @@ const confirmSaveWorkflow = async () => {
},
...(n.data?.preTool ? { preTool: n.data.preTool } : {}),
isSaveFile: Boolean(n.data?.isSaveFile),
- subConfig: null,
+ // 子流程节点:序列化引入的工作流配置(含 workflowId 与 isFormField 标记);其余节点保持 null
+ subConfig: nodeCode === 'sub_flow' ? serializeSubFlowConfig(n.data?.subFlowConfig, n, startNode?.id || '') : null,
modelConfig: {
modelId: n.data?.modelConfig?.modelId || '',
// 模型名称/类型随工作流保存,供首页补全弹窗展示模型名与「同类型模型」过滤
@@ -1008,11 +1354,9 @@ const confirmSaveWorkflow = async () => {
...(n.data?.modelConfig?.modelType !== undefined && n.data.modelConfig.modelType !== null && n.data.modelConfig.modelType !== ''
? { modelType: n.data.modelConfig.modelType }
: {}),
- modelRequestParams: n.data?.modelConfig?.modelRequestParams ?? null,
- // 保存时实时收集勾选的「表单展示」字段(路径带实例索引)
- ...(n.data?.modelConfig?.modelRequestParams
- ? { modelFormFields: collectExposedFields(n.data.modelConfig.modelRequestParams) }
- : {}),
+ modelRequestParams: savedModelRequestParams,
+ // 勾选的「表单展示」字段(路径带实例索引),已在上方基于完整结构收集
+ ...(savedModelFormFields && savedModelFormFields.length > 0 ? { modelFormFields: savedModelFormFields } : {}),
// 模型返回参数随工作流保存,保证重开后仍可被下游引用
modelResponseBodyMapping: n.data?.modelConfig?.modelResponseBodyMapping ?? null,
},
@@ -1021,6 +1365,8 @@ const confirmSaveWorkflow = async () => {
...(n.data?.skillName ? { skillName: n.data.skillName } : {}),
...(n.data?.prompt ? { prompt: n.data.prompt } : {}),
...(n.data?.negativePrompt ? { negativePrompt: n.data.negativePrompt } : {}),
+ // 节点描述:与加载端 loadWorkflowFromDsl 的 n.desc 读取对齐,空描述不序列化
+ ...(n.data?.desc ? { desc: n.data.desc } : {}),
...(n.data?.patchLayout ? { patchLayout: n.data.patchLayout } : {}),
outputResult: null,
};