// ===== 首页执行工作流管理 DSL 的解析与写回工具 ===== // 独立实现,不依赖 settings/workflow 或 settings/creation 的旧结构。 // 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .enumValues[i] export interface HomeFormField { path: string; label: string; type: string; fieldType: string; required: boolean; value?: any; 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; } export function deepClone(val: T): T { return JSON.parse(JSON.stringify(val ?? null)) as T; } // 开始节点运行字段(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' && Array.isArray(f?.options)) return 'select'; if (ft === 'textarea') return 'textarea'; if (ft === 'uploadMultiple' || t === 'uploadMultiple' || f?.multiple) return 'uploadMultiple'; if (ft === 'upload' || ft === 'file' || ft === 'fileUpload' || t === 'upload' || t === 'file') return 'upload'; return 'input'; } // 选项归一化:兼容 {label,value} / {key,value} / value.options 嵌套三种来源 function normalizeOptions(def: any): any[] | undefined { let raw: any[] | undefined; if (Array.isArray(def.options) && def.options.length > 0) raw = def.options; else if (def.value && typeof def.value === 'object' && !Array.isArray(def.value) && Array.isArray(def.value.options)) raw = def.value.options; else if (Array.isArray(def.enumValues) && def.enumValues.length > 0) raw = def.enumValues; if (!raw) return undefined; return raw.map((o: any) => { if (o && typeof o === 'object' && !Array.isArray(o)) { const val = o.value !== undefined ? o.value : o.key; const label = o.label !== undefined ? o.label : o.key; return { label: label === undefined ? String(val) : label, value: val }; } return { label: String(o), value: o }; }); } // 开始节点(__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 || 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, }; }); } // 主入口:DSL 节点 → 首页扁平表单字段。开始节点为唯一表单源 // (runFormFields 汇总了各 model 节点勾选字段 + form 节点自定义字段),其余节点无运行时字段。 export function collectHomeFormFields(node: any): HomeFormField[] { if (String(node?.nodeCode || '').toLowerCase() !== '__start__') return []; return collectStartNodeFields(node); } // 上传类型空值:未选文件(空字符串 / 空数组 / null) const isEmptyUploadValue = (val: any): boolean => val === '' || val === null || (Array.isArray(val) && val.length === 0); // 首页表单值写回开始节点(唯一表单源)outputConfig 对应条目 value: // 1. 引用其他节点输出(valueSource)的字段只读展示,值由上游节点运行时提供, // 清空保存时的值快照(避免后端误用过期值) // 2. 上传类型字段未选文件 → 从开始节点运行字段移除,不向后端提交空字段 export function applyHomeFormValues(nodes: any[], formValues: Record, formFileNames?: Record): void { if (!Array.isArray(nodes)) return; 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]; } } } // 模板完整性校验:返回缺模型等缺失项,供补全弹窗提示 export function checkTemplateMissing(flowContent: any): { nodeId: string; nodeName: string; reason: string }[] { const nodes = Array.isArray(flowContent?.nodes) ? flowContent.nodes : []; const missing: { nodeId: string; nodeName: string; reason: string }[] = []; for (const n of nodes) { if (String(n?.nodeCode || '').toLowerCase() === 'model' && !n?.modelConfig?.modelId) { missing.push({ nodeId: n?.id || '', nodeName: n?.name || n?.nodeCode || '模型节点', reason: '未选择模型' }); } } return missing; } // ===== 执行产出提取 ===== // 会话内展示工作流执行产出(文件卡片):来自 getExecutionDetail 返回的 WorkflowItem, // 合并 fileUrls / outputParams 各 value / resultUrl,去重后归一为 { url, name, type, backendId }。 // type 用于预览模式判定(image/video/audio/text/file),backendId 为执行记录 id(删除产出用)。 export interface WorkflowOutput { url: string; name: string; type: string; backendId: string; } // 按文件扩展名推断预览类型 function guessFileType(url: string): string { const clean = String(url).split('?')[0] || ''; const ext = (clean.split('.').pop() || '').toLowerCase(); if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext)) return 'image'; if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video'; if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio'; if (['txt', 'md', 'json', 'log', 'csv'].includes(ext)) return 'text'; return 'file'; } export function extractWorkflowOutputs(data: any, backendId?: string): WorkflowOutput[] { if (!data) return []; const seen = new Set(); const outputs: WorkflowOutput[] = []; const push = (rawUrl: any) => { if (typeof rawUrl !== 'string') return; const url = rawUrl.trim(); if (!url || seen.has(url)) return; seen.add(url); const name = String(url).split('?')[0].split('/').pop() || '产出文件'; outputs.push({ url, name, type: guessFileType(url), backendId: backendId || data.id || '', }); }; if (Array.isArray(data.fileUrls)) data.fileUrls.forEach(push); if (Array.isArray(data.outputParams)) { data.outputParams.forEach((o: any) => { if (!o || typeof o !== 'object') return; Object.values(o).forEach((v) => push(v)); }); } if (data.resultUrl) push(data.resultUrl); return outputs; } // 从事件携带的文件 URL 数组(flow_complete 的 data.resultFileUrls)直接构建产出列表: // 不依赖执行详情的异步查询,type 同样按扩展名推断,去重后归一为 { url, name, type, backendId }。 // 用于完成事件即时渲染产出卡片,删除时 backendId 为空则跳过删除接口(安全降级)。 export function buildOutputsFromUrls(urls: any, backendId?: string): WorkflowOutput[] { // 兼容两种入参:字符串(逗号拼接多 URL)或数组(元素本身可能是逗号拼接串); // 合法 URL 不含裸逗号,统一按逗号拆分后逐条归一化,避免把整串误当单个产出 const arr: string[] = typeof urls === 'string' ? [urls] : Array.isArray(urls) ? urls : []; const seen = new Set(); const outputs: WorkflowOutput[] = []; for (const u of arr) { if (typeof u !== 'string') continue; const parts = u .split(',') .map((s) => s.trim()) .filter(Boolean); for (const url of parts) { if (seen.has(url)) continue; seen.add(url); const name = String(url).split('?')[0].split('/').pop() || '产出文件'; outputs.push({ url, name, type: guessFileType(url), backendId: backendId || '', }); } } return outputs; }