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