Files
admin-ui/src/views/home/utils/flowDsl.ts
T

227 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===== 首页执行工作流管理 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<T>(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<string, any>, formFileNames?: Record<string, any>): 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<string>();
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<string>();
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;
}