diff --git a/src/api/settings/workflow/index.ts b/src/api/settings/workflow/index.ts index ae47272..98b8908 100644 --- a/src/api/settings/workflow/index.ts +++ b/src/api/settings/workflow/index.ts @@ -52,12 +52,20 @@ export interface WorkflowModelItem { baseUrl?: string; enabled?: number | boolean; isOwner?: number; + // true = 系统内置模型(选它需填 API Key 转用户模型);false/缺省 = 用户模型 + systemModel?: boolean; // 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致) requestBodyMapping?: Record; [key: string]: any; } -export function getWorkflowModelList(params?: { pageNum: number; pageSize: number; modelName?: string }) { +export function getWorkflowModelList(params?: { + pageNum: number; + pageSize: number; + modelName?: string; + isSameType?: boolean; + modelType?: string | number; +}) { return request({ url: '/model-gateway/model/manage/listModelManage', method: 'get', diff --git a/src/views/home/components/InputBar.vue b/src/views/home/components/InputBar.vue index 42a6421..f11a2ba 100644 --- a/src/views/home/components/InputBar.vue +++ b/src/views/home/components/InputBar.vue @@ -57,6 +57,7 @@ > {{ wf.name }} + 模板 @@ -75,13 +76,15 @@ interface Props { interface Emits { (e: 'send', message: string): void; - (e: 'workflow-select', workflowId: string | null): void; + (e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void; } interface Workflow { id: string; name: string; prefix: string; + isTemplate: boolean; + raw?: any; } const props = withDefaults(defineProps(), { @@ -104,11 +107,18 @@ const visibleWorkflows = computed(() => { const fetchWorkflows = async () => { try { const res = await getWorkflowList(); - const workflows = res.data?.listFlowUserRes?.list || []; + const userList = res.data?.listFlowUserRes?.list || []; + const tplList = res.data?.listFlowTemplateRes?.list || []; + const workflows = [ + ...userList.map((w) => ({ ...w, isTemplate: false })), + ...tplList.map((w) => ({ ...w, isTemplate: true })), + ]; commonWorkflows.value = workflows.map((w) => ({ id: String(w.id), - name: w.flowName || '未命名', - prefix: '[工作流] ' + (w.flowName || '') + ':\n', + name: w.flowName || w.flowTemplateName || '未命名', + prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n', + isTemplate: !!w.isTemplate, + raw: w, })); } catch { commonWorkflows.value = []; @@ -129,9 +139,10 @@ const handleAttachment = () => { const toggleWorkflow = (id: string) => { if (props.workflowLocked) return; + const item = commonWorkflows.value.find((w) => w.id === id); const newId = selectedWorkflowId.value === id ? null : id; selectedWorkflowId.value = newId; - emit('workflow-select', newId); + emit('workflow-select', newId, item?.isTemplate || false); }; const resetAll = () => { @@ -146,11 +157,17 @@ const resetAll = () => { emit('workflow-select', null); }; +const selectWorkflow = (id: string | null) => { + if (props.workflowLocked) return; + selectedWorkflowId.value = id; + emit('workflow-select', id, false); +}; + onMounted(() => { fetchWorkflows(); }); - defineExpose({ resetAll, clearWorkflow, selectedWorkflowId, commonWorkflows }); + defineExpose({ resetAll, clearWorkflow, selectWorkflow, fetchWorkflows, selectedWorkflowId, commonWorkflows }); diff --git a/src/views/home/components/MainContent.vue b/src/views/home/components/MainContent.vue index 2b16840..d344570 100644 --- a/src/views/home/components/MainContent.vue +++ b/src/views/home/components/MainContent.vue @@ -190,6 +190,7 @@ import { computed, reactive, ref, watch } from 'vue'; import { ElMessage } from 'element-plus'; import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue'; import { uploadFile } from '/@/api/common/upload'; +import { collectHomeFormFields } from '../utils/flowDsl'; interface Props { activeMenu: string; @@ -205,42 +206,12 @@ const fieldFiles = reactive>({}) const uploadingFields = reactive>({}); const templates = ref([]); const getFieldKey = (node: any, field: any): string => { - if (field?.__isHttpBodyChild) { - return `${node.id}_body_${field.bodyKey}`; - } - return (node.id || node.nodeCode) + '_' + (field.field || field.label); + const id = node.id || node.nodeCode; + return `${id}|${field.path || field.field || field.label}`; }; const getVisibleFields = (node: any): any[] => { - const fields = Array.isArray(node?.formConfig) ? node.formConfig : []; - const result: any[] = []; - 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, - field: `body.${bodyKey}`, - label: bodyItem.key || bodyKey, - required: false, - type: bodyItem.fieldType || 'input', - fieldType: bodyItem.fieldType || 'string', - fieldConstraint: bodyItem.fieldConstraint || {}, - }); - }); - return; - } - - result.push(field); - }); - return result; + return collectHomeFormFields(node); }; const isFileField = (field: any): boolean => { @@ -355,13 +326,13 @@ const currentWorkflowHasPatchLayout = computed(() => { }); const hasFormConfig = (node: any): boolean => { - return node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0; + return node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0; }; const hasFormFields = computed(() => { if (!props.workflowDetail?.nodeInputParams) return false; return props.workflowDetail.nodeInputParams.some( - (node: any) => node.nodeCode !== '__start__' && node.formConfig?.length > 0 + (node: any) => node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0 ); }); @@ -396,43 +367,42 @@ watch( templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : []; if (!detail?.nodeInputParams) return; - detail.nodeInputParams.forEach((node: any) => { - if (!node.formConfig) return; - node.formConfig.forEach((field: any) => { - if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') { - Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => { - if (!bodyItem || bodyItem.showInForm !== true) return; - const bodyFieldKey = `${node.id}_body_${bodyKey}`; - if (bodyItem.fieldType === 'number') { - formValues[bodyFieldKey] = (bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '') - ? Number(bodyItem.value) : null; - } else if (bodyItem.fieldType === 'fileUpload') { - formValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value - : bodyItem.value ? [bodyItem.value] : []; - } else { - formValues[bodyFieldKey] = bodyItem.value || ''; - } - }); - return; - } + const nodes = detail.nodeInputParams as any[]; + // 1) 初始化表单值(model → modelRequestParams runtimeShow;form → outputConfig;旧数据 → formConfig) + nodes.forEach((node) => { + collectHomeFormFields(node).forEach((field) => { const key = getFieldKey(node, field); - const hasValue = field.value !== undefined && field.value !== null; + const hasValue = field.value !== undefined && field.value !== null && field.value !== ''; if (field.type === 'number' || field.type === 'inputNumber') { formValues[key] = hasValue ? Number(field.value) : (field.default ?? null); } else if (field.type === 'switch') { formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false); } else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') { - formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : [])); + if (field.type === 'fileUpload') { + formValues[key] = hasValue + ? Array.isArray(field.value) + ? field.value + : field.value + ? [field.value] + : [] + : Array.isArray(field.default) + ? field.default + : field.default + ? [field.default] + : []; + } else { + formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : [])); + } } else { formValues[key] = hasValue ? field.value : (field.default ?? ''); } }); }); - detail.nodeInputParams.forEach((node: any) => { - if (!node.formConfig) return; - node.formConfig.forEach((field: any) => { + // 2) 文件字段回填已上传文件列表 + nodes.forEach((node) => { + collectHomeFormFields(node).forEach((field) => { if (!isFileField(field)) return; const key = getFieldKey(node, field); const rawValue = formValues[key]; diff --git a/src/views/home/components/TemplateCompleteDialog.vue b/src/views/home/components/TemplateCompleteDialog.vue new file mode 100644 index 0000000..23e9e60 --- /dev/null +++ b/src/views/home/components/TemplateCompleteDialog.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/src/views/home/index.vue b/src/views/home/index.vue index aa696d3..ff91cb3 100644 --- a/src/views/home/index.vue +++ b/src/views/home/index.vue @@ -39,6 +39,13 @@ + + + @@ -48,6 +55,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'; 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 { applyHomeFormValues } from './utils/flowDsl'; import type { ExecutionTreeItem } from '/@/api/settings/creation'; import { getExecutionList, @@ -264,6 +273,8 @@ const mainContentRef = ref(null); const sendingSessions = reactive>({}); const isHistoryWorkflow = ref(false); const inputBarRef = ref(null); +const templateDialogVisible = ref(false); +const pendingTemplate = ref(null); const isSendDisabled = computed(() => { if (!activeHistoryId.value) return false; @@ -276,11 +287,22 @@ const getSessionId = () => { return `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; }; -const handleWorkflowSelect = async (workflowId: string | null) => { +const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: boolean) => { if (workflowId === null) { selectedWorkflowDetail.value = null; return; } + if (isTemplate) { + // 系统模板:不跳转工作流管理,就地弹出补全弹窗,保存为用户工作流后再复用 + const item = inputBarRef.value?.commonWorkflows?.find((w: any) => w.id === workflowId) || null; + pendingTemplate.value = { + id: workflowId, + flowName: item?.name || '系统模板', + raw: item?.raw || null, + }; + templateDialogVisible.value = true; + return; + } try { const res = await getWorkflowDetail(workflowId); selectedWorkflowDetail.value = res.data || null; @@ -289,6 +311,15 @@ const handleWorkflowSelect = async (workflowId: string | null) => { } }; +// 模板补全保存完成:刷新列表并自动选中新保存的用户工作流 +const handleTemplateSaved = async (newId: string) => { + templateDialogVisible.value = false; + pendingTemplate.value = null; + const ib = inputBarRef.value as any; + if (ib?.fetchWorkflows) await ib.fetchWorkflows(); + ib?.selectWorkflow?.(newId); +}; + const handleMenuChange = (menu: string) => { activeMenu.value = menu; }; @@ -355,38 +386,9 @@ const handleSend = async (message: string) => { const sessionId = curSession.sessionId || getSessionId(); try { - // 1. 构建节点输入参数 - const nodeInputParams = - selectedWorkflowDetail.value.nodeInputParams?.map((node: any) => { - const nodeParam: any = { ...node }; - - if (node.formConfig && Array.isArray(node.formConfig)) { - nodeParam.formConfig = node.formConfig.map((field: any) => { - // HTTP body 处理 - if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') { - const bodyValue = { ...field.value }; - Object.entries(bodyValue).forEach(([bodyKey, bodyItem]: [string, any]) => { - if (!bodyItem || bodyItem.showInForm !== true) return; - const bodyFieldKey = `${node.id}_body_${bodyKey}`; - const userVal = mc.formValues[bodyFieldKey]; - bodyValue[bodyKey] = { - ...bodyItem, - value: userVal !== undefined ? userVal : bodyItem.value, - }; - }); - return { ...field, value: bodyValue }; - } - - const fieldKey = `${node.id}_${field.field || field.label}`; - return { - ...field, - value: mc.formValues[fieldKey] !== undefined ? mc.formValues[fieldKey] : field.value, - }; - }); - } - - return nodeParam; - }) || []; + // 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回对应字段(model → modelRequestParams;form → outputConfig) + const nodeInputParams = JSON.parse(JSON.stringify(selectedWorkflowDetail.value.nodeInputParams || [])); + applyHomeFormValues(nodeInputParams, mc.formValues); // 2. 构建 flowContent const updatedFlowContent = { @@ -500,7 +502,7 @@ const handleSelectHistory = async (id: string) => { // 同步回显 InputBar 的工作流选择 const ib = inputBarRef.value as any; if (ib?.commonWorkflows && res.data.flowName) { - const match = ib.commonWorkflows.find((w: any) => w.name === res.data.flowName); + const match = ib.commonWorkflows.find((w: any) => !w.isTemplate && w.name === res.data.flowName); if (match) ib.selectedWorkflowId = match.id; } } diff --git a/src/views/home/utils/flowDsl.ts b/src/views/home/utils/flowDsl.ts new file mode 100644 index 0000000..3755e33 --- /dev/null +++ b/src/views/home/utils/flowDsl.ts @@ -0,0 +1,239 @@ +// ===== 首页执行工作流管理 DSL 的解析与写回工具 ===== +// 独立实现,不依赖 settings/workflow 或 settings/creation 的旧结构。 +// 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .value[i] + +export interface HomeFormField { + path: string; + label: string; + type: string; + fieldType: string; + required: boolean; + value?: any; + default?: any; + options?: any[]; + fieldConstraint?: any; + // 旧兼容:creation 时代 http body 中 showInForm 的子字段 + __isHttpBodyChild?: boolean; + bodyKey?: string; +} + +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'; + if (ft === 'number' || t === 'number') return 'number'; + if (ft === 'boolean' || t === 'boolean' || ft === 'switch') return 'switch'; + if (ft === 'select') return 'select'; + if (ft === 'textarea') return 'textarea'; + if (ft === 'upload' || ft === 'file' || ft === 'fileUpload') { + return ft === 'uploadMultiple' || def.multiple ? 'uploadMultiple' : '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 }; + }); +} + +// 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[] { + 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, + }); + }); + return result; +} + +// 主入口:DSL 节点 → 首页扁平表单字段 +// model → modelRequestParams runtimeShow 叶子;form → outputConfig;其余 → 旧 formConfig 兜底 +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); +} + +// 首页表单值写回 DSL 对应字段(model → modelRequestParams.path.value;form → outputConfig[].value) +export function applyHomeFormValues(nodes: any[], formValues: 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; + 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; + } + } + } + } +} + +// 模板完整性校验:返回缺模型等缺失项,供补全弹窗提示 +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; +} diff --git a/src/views/settings/workflow/component/ModelSelector.vue b/src/views/settings/workflow/component/ModelSelector.vue index 025e9a1..9709be2 100644 --- a/src/views/settings/workflow/component/ModelSelector.vue +++ b/src/views/settings/workflow/component/ModelSelector.vue @@ -22,7 +22,11 @@ @click="handleSelectModel(model)" >
-
{{ getModelTypeName(model.modelType) }}
+
+ {{ getModelTypeName(model.modelType) }} + 内置 + 我的 +
@@ -56,16 +60,56 @@ 确定 + + + + + + + + + + +